diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,80 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main where
+
+import Control.Monad (join)
+import Options.Applicative
+import Security.Advisories.Sync
+import System.Exit (die)
+
+main :: IO ()
+main =
+  join $
+    customExecParser
+      (prefs showHelpOnEmpty)
+      cliOpts
+
+cliOpts :: ParserInfo (IO ())
+cliOpts = info (commandsParser <**> helper) (fullDesc <> header "Haskell Advisories tools")
+  where
+    commandsParser :: Parser (IO ())
+    commandsParser =
+      hsubparser
+        ( command "sync" (info commandSync (progDesc "Synchronize a local Haskell Security Advisory repository"))
+        <>  command "status" (info commandStatus (progDesc "Check the status of a local Haskell Security Advisory repository"))
+        )
+
+commandSync :: Parser (IO ())
+commandSync = go <$> repositoryParser
+  where
+    go repo = do
+      result <- sync repo
+      case result of
+        Left e ->
+          die e
+        Right s -> do
+          putStrLn $
+            "Repository at "
+              <> show (repositoryRoot repo)
+              <> " from "
+              <> show (repositoryUrl repo <> "@" <> repositoryBranch repo)
+          putStrLn $
+            case s of
+              Created -> "Repository just created"
+              Updated -> "Repository updated"
+              AlreadyUpToDate -> "Repository already up-to-date"
+
+commandStatus :: Parser (IO ())
+commandStatus = go <$> repositoryParser
+  where
+    go repo = do
+      result <- status repo
+      putStrLn $
+        case result of
+          DirectoryMissing -> "Directory is missing"
+          DirectoryEmpty -> "Directory is empty"
+          DirectoryUpToDate -> "Repository is up-to-date"
+          DirectoryOutDated -> "Repository is out-dated"
+
+repositoryParser :: Parser Repository
+repositoryParser =
+  Repository
+    <$> strOption
+      ( long "repository-root"
+          <> short 'd'
+          <> metavar "REPOSITORY-ROOT"
+          <> value (repositoryRoot defaultRepository)
+      )
+    <*> strOption
+      ( long "repository-url"
+          <> short 'r'
+          <> metavar "REPOSITORY-URL"
+          <> value (repositoryUrl defaultRepository)
+      )
+    <*> strOption
+      ( long "repository-branch"
+          <> short 'b'
+          <> metavar "REPOSITORY-BRANCH"
+          <> value (repositoryBranch defaultRepository)
+      )
diff --git a/hsec-sync.cabal b/hsec-sync.cabal
new file mode 100644
--- /dev/null
+++ b/hsec-sync.cabal
@@ -0,0 +1,96 @@
+cabal-version:   2.4
+name:            hsec-sync
+version:         0.1.0.0
+
+-- A short (one-line) description of the package.
+synopsis:        Synchronize with the Haskell security advisory database
+
+-- A longer description of the package.
+description:     Synchronize with the Haskell security advisory database.
+
+-- A URL where users can report bugs.
+-- bug-reports:
+
+-- The license under which the package is released.
+license:         BSD-3-Clause
+author:          Gautier DI FOLCO
+maintainer:      gautier.difolco@gmail.com
+
+-- A copyright notice.
+-- copyright:
+category:        Data
+extra-doc-files: CHANGELOG.md
+tested-with:
+  GHC ==8.10.7 || ==9.0.2 || ==9.2.8 || ==9.4.8 || ==9.6.3 || ==9.8.1
+
+library
+  exposed-modules:  Security.Advisories.Sync
+  other-modules:
+    Security.Advisories.Sync.Atom
+    Security.Advisories.Sync.Git
+
+  build-depends:
+    , base         >=4.14  && <4.20
+    , directory    >=1.3   && <1.4
+    , extra        >=1.7   && <1.8
+    , feed         >=1.3   && <1.4
+    , filepath     >=1.4   && <1.5
+    , hsec-core    >= 1.0  && < 2.0
+    , http-client  >=0.7.0 && <0.8
+    , lens         >=5.1   && <5.3
+    , process      >=1.6   && <1.7
+    , text         >=1.2   && <3
+    , time         >=1.9   && <1.14
+    , transformers >=0.5   && <0.7
+    , wreq         >=0.5   && <0.6
+
+  hs-source-dirs:   src
+  default-language: Haskell2010
+  ghc-options:
+    -Wall -Wcompat -Widentities -Wincomplete-record-updates
+    -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints
+
+executable hsec-sync
+  main-is:          Main.hs
+
+  -- Modules included in this executable, other than Main.
+  -- other-modules:
+
+  -- LANGUAGE extensions used by modules in this package.
+  -- other-extensions:
+  build-depends:
+    , aeson                 >=2.0.1.0 && <3
+    , base                  >=4.14    && <4.20
+    , bytestring            >=0.10    && <0.13
+    , filepath              >=1.4     && <1.5
+    , hsec-sync
+    , optparse-applicative  >=0.17    && <0.19
+    , text                  >=1.2     && <3
+
+  hs-source-dirs:   app
+  default-language: Haskell2010
+  ghc-options:
+    -Wall -Wcompat -Widentities -Wincomplete-record-updates
+    -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints
+
+test-suite spec
+  type:             exitcode-stdio-1.0
+  hs-source-dirs:   test
+  main-is:          Spec.hs
+  other-modules:    Spec.SyncSpec
+  build-depends:
+    , base         <5
+    , directory
+    , hsec-sync
+    , filepath
+    , process
+    , tasty        <1.5
+    , tasty-hunit  <0.11
+    , temporary    ==1.*
+    , text
+    , time
+
+  default-language: Haskell2010
+  ghc-options:
+    -Wall -Wcompat -Widentities -Wincomplete-record-updates
+    -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints
diff --git a/src/Security/Advisories/Sync.hs b/src/Security/Advisories/Sync.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/Advisories/Sync.hs
@@ -0,0 +1,76 @@
+{-# LANGUAGE DerivingStrategies #-}
+
+module Security.Advisories.Sync
+  ( Repository (..),
+    defaultRepository,
+    SyncStatus (..),
+    sync,
+    RepositoryStatus (..),
+    status,
+  )
+where
+
+import Data.Time (zonedTimeToUTC)
+import Security.Advisories.Sync.Atom
+import Security.Advisories.Sync.Git
+
+data SyncStatus
+  = Created
+  | Updated
+  | AlreadyUpToDate
+  deriving stock (Eq, Show)
+
+sync :: Repository -> IO (Either String SyncStatus)
+sync repo = do
+  gitStatus <- gitRepositoryStatus repo
+  ensured <- ensureGitRepositoryWithRemote repo gitStatus
+  let mkGitError = Left . explainGitError
+  case ensured of
+    Left e -> return $ mkGitError e
+    Right s ->
+      case s of
+        GitRepositoryCreated ->
+          return $ Right Created
+        GitRepositoryExisting -> do
+          repoStatus <- status' repo gitStatus
+          if repoStatus == DirectoryOutDated
+            then either mkGitError (const $ Right Updated) <$> updateGitRepository repo
+            else return $ Right AlreadyUpToDate
+
+data RepositoryStatus
+  = DirectoryMissing
+  | DirectoryEmpty
+  | DirectoryUpToDate
+  | DirectoryOutDated
+  deriving stock (Eq, Show)
+
+status :: Repository -> IO RepositoryStatus
+status repo =
+  status' repo =<< gitRepositoryStatus repo
+
+status' :: Repository -> GitRepositoryStatus -> IO RepositoryStatus
+status' repo gitStatus = do
+  case gitStatus of
+    GitDirectoryMissing ->
+      return DirectoryMissing
+    GitDirectoryEmpty ->
+      return DirectoryEmpty
+    GitDirectoryInitialized -> do
+      gitInfo <- getDirectoryGitInfo $ repositoryRoot repo
+      case gitInfo of
+        Left _ ->
+          return DirectoryOutDated
+        Right info -> do
+          update <- latestUpdate (repositoryUrl repo) (repositoryBranch repo)
+          return $
+            if update == Right (zonedTimeToUTC $ lastModificationCommitDate info)
+              then DirectoryUpToDate
+              else DirectoryOutDated
+
+defaultRepository :: Repository
+defaultRepository =
+  Repository
+    { repositoryUrl = "https://github.com/haskell/security-advisories",
+      repositoryRoot = "security-advisories",
+      repositoryBranch = "main"
+    }
diff --git a/src/Security/Advisories/Sync/Atom.hs b/src/Security/Advisories/Sync/Atom.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/Advisories/Sync/Atom.hs
@@ -0,0 +1,59 @@
+module Security.Advisories.Sync.Atom
+  ( latestUpdate,
+  )
+where
+
+import Control.Exception (try)
+import Control.Lens
+import Data.Either.Extra (maybeToEither)
+import qualified Data.Text as T
+import Data.Time (UTCTime, defaultTimeLocale, parseTimeM, rfc822DateFormat)
+import Data.Time.Format.ISO8601 (iso8601ParseM)
+import Network.HTTP.Client (HttpException (..), HttpExceptionContent (..))
+import Network.Wreq
+import qualified Text.Atom.Feed as FeedAtom
+import qualified Text.Feed.Import as FeedImport
+import qualified Text.Feed.Types as FeedTypes
+import qualified Text.RSS.Syntax as FeedRSS
+
+latestUpdate :: String -> String -> IO (Either String UTCTime)
+latestUpdate repoUrl branch = do
+  resultE <- try $ get $ repoUrl </> "commits" </> branch </> "advisories.atom"
+  return $
+    case resultE of
+      Left e ->
+        Left $
+          case e of
+            InvalidUrlException url reason ->
+              "Invalid URL " <> show url <> ": " <> show reason
+            HttpExceptionRequest _ content ->
+              case content of
+                StatusCodeException response body ->
+                  "Request failed with " <> show (response ^. responseStatus) <> ": " <> show body
+                _ ->
+                  "Request failed: " <> show content
+      Right result ->
+        case FeedImport.parseFeedSource $ result ^. responseBody of
+          Just (FeedTypes.AtomFeed x) ->
+            maybeToEither "Invalid feed date" $
+              iso8601ParseM $
+                T.unpack $
+                  FeedAtom.feedUpdated x
+          Just (FeedTypes.RSSFeed x) ->
+            maybeToEither "Invalid feed date" $
+              FeedRSS.rssLastUpdate (FeedRSS.rssChannel x)
+                >>= parseTimeM True defaultTimeLocale rfc822DateFormat . T.unpack
+          Just (FeedTypes.RSS1Feed _) -> Left "RSS1 feed are not supported"
+          Just (FeedTypes.XMLFeed _) -> Left "XML feed are not supported"
+          Nothing -> Left "No feed found"
+
+infixr 5 </>
+
+(</>) :: String -> String -> String
+"/" </> ('/' : ys) = '/' : ys
+"/" </> ys = '/' : ys
+"" </> ('/' : ys) = '/' : ys
+"" </> ys = '/' : ys
+[x] </> ('/' : ys) = x : '/' : ys
+[x] </> ys = x : '/' : ys
+(x0 : x1 : xs) </> ys = x0 : ((x1 : xs) </> ys)
diff --git a/src/Security/Advisories/Sync/Git.hs b/src/Security/Advisories/Sync/Git.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/Advisories/Sync/Git.hs
@@ -0,0 +1,172 @@
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE LambdaCase #-}
+
+-- |
+--
+-- Helpers for deriving advisory metadata from a Git repo.
+module Security.Advisories.Sync.Git
+  ( GitDirectoryInfo (..),
+    GitError (..),
+    GitErrorCase (..),
+    explainGitError,
+    Repository (..),
+    GitRepositoryEnsuredStatus (..),
+    ensureGitRepositoryWithRemote,
+    getDirectoryGitInfo,
+    updateGitRepository,
+    GitRepositoryStatus (..),
+    gitRepositoryStatus,
+  )
+where
+
+import Control.Monad (when)
+import Control.Monad.IO.Class (liftIO)
+import Control.Monad.Trans.Except (runExceptT, throwE)
+import Data.Time (ZonedTime)
+import Data.Time.Format.ISO8601 (iso8601ParseM)
+import qualified System.Directory as D
+import System.Exit (ExitCode (ExitSuccess))
+import System.Process (readProcessWithExitCode)
+
+type Cmd = (FilePath, [String])
+
+data GitError = GitError
+  { gitCmd :: Cmd,
+    gitError :: GitErrorCase
+  }
+
+runGit :: [String] -> IO (Cmd, ExitCode, String, String)
+runGit args =
+  (\(status, stdout, stderr) -> (("git", args), status, stdout, stderr))
+    <$> readProcessWithExitCode "git" args ""
+
+data GitErrorCase
+  = -- | exit code, stdout and stderr
+    GitProcessError ExitCode String String
+  | -- | unable to parse this input as a datetime
+    GitTimeParseError String
+  deriving (Show)
+
+explainGitError :: GitError -> String
+explainGitError e =
+  unlines
+    [ "Called " <> show (fst $ gitCmd e) <> " with " <> show (snd $ gitCmd e),
+      case gitError e of
+        GitProcessError status stdout stderr ->
+          unlines
+            [ "git exited with status " <> show status,
+              ">>> standard output:",
+              stdout,
+              ">>> standard error:",
+              stderr
+            ]
+        GitTimeParseError s ->
+          "failed to parse time: " <> s
+    ]
+
+data Repository = Repository
+  { repositoryRoot :: FilePath,
+    repositoryUrl :: String,
+    repositoryBranch :: String
+  }
+
+data GitRepositoryStatus
+  = GitDirectoryMissing
+  | GitDirectoryEmpty
+  | GitDirectoryInitialized
+
+gitRepositoryStatus :: Repository -> IO GitRepositoryStatus
+gitRepositoryStatus repo = do
+  exists <- D.doesDirectoryExist $ repositoryRoot repo
+  if exists
+    then D.withCurrentDirectory (repositoryRoot repo) $ do
+      (_, checkStatus, checkStdout, _) <-
+        runGit ["rev-parse", "--is-inside-work-tree"]
+      let out = filter (not . null) $ lines checkStdout
+      case checkStatus of
+        ExitSuccess
+          | not (null out) && head out == "true" ->
+              return GitDirectoryInitialized
+        _ ->
+          return GitDirectoryEmpty
+    else return GitDirectoryMissing
+
+data GitRepositoryEnsuredStatus
+  = GitRepositoryCreated
+  | GitRepositoryExisting
+
+ensureGitRepositoryWithRemote ::
+  Repository ->
+  GitRepositoryStatus ->
+  IO (Either GitError GitRepositoryEnsuredStatus)
+ensureGitRepositoryWithRemote repo =
+  \case
+    GitDirectoryMissing ->
+      clone
+    GitDirectoryEmpty ->
+      clone
+    GitDirectoryInitialized ->
+      return $ Right GitRepositoryExisting
+  where
+    clone = do
+      (cmd, status, stdout, stderr) <-
+        runGit ["clone", "-b", repositoryBranch repo, repositoryUrl repo, repositoryRoot repo]
+      return $
+        if status /= ExitSuccess
+          then Left $ GitError cmd $ GitProcessError status stdout stderr
+          else Right GitRepositoryCreated
+
+updateGitRepository :: Repository -> IO (Either GitError ())
+updateGitRepository repo =
+  D.withCurrentDirectory (repositoryRoot repo) $
+    runExceptT $ do
+      _ <- liftIO $ runGit ["remote", "add", "origin", repositoryUrl repo] -- can fail if it exists
+      (setUrlCmd, setUrlStatus, setUrlStdout, setUrlStderr) <-
+        liftIO $ runGit ["remote", "set-url", "origin", repositoryUrl repo]
+      when (setUrlStatus /= ExitSuccess) $
+        throwE $
+          GitError setUrlCmd $
+            GitProcessError setUrlStatus setUrlStdout setUrlStderr
+
+      (fetchAllCmd, fetchAllStatus, fetchAllStdout, fetchAllStderr) <-
+        liftIO $ runGit ["fetch", "--all"]
+      when (fetchAllStatus /= ExitSuccess) $
+        throwE $
+          GitError fetchAllCmd $
+            GitProcessError fetchAllStatus fetchAllStdout fetchAllStderr
+
+      (checkoutBranchCmd, checkoutBranchStatus, checkoutBranchStdout, checkoutBranchStderr) <-
+        liftIO $ runGit ["checkout", repositoryBranch repo]
+      when (checkoutBranchStatus /= ExitSuccess) $
+        throwE $
+          GitError checkoutBranchCmd $
+            GitProcessError checkoutBranchStatus checkoutBranchStdout checkoutBranchStderr
+
+      (resetCmd, resetStatus, resetStdout, resetStderr) <-
+        liftIO $ runGit ["reset", "--hard", "origin/" <> repositoryBranch repo]
+
+      when (resetStatus /= ExitSuccess) $
+        throwE $
+          GitError resetCmd $
+            GitProcessError resetStatus resetStdout resetStderr
+
+newtype GitDirectoryInfo = GitDirectoryInfo
+  { lastModificationCommitDate :: ZonedTime
+  }
+
+getDirectoryGitInfo :: FilePath -> IO (Either GitError GitDirectoryInfo)
+getDirectoryGitInfo path = do
+  (cmd, status, stdout, stderr) <-
+    runGit ["-C", path, "log", "--pretty=format:%cI", "--find-renames", "advisories"]
+  let timestamps = filter (not . null) $ lines stdout
+      onError = Left . GitError cmd
+  case status of
+    ExitSuccess
+      | not (null timestamps) ->
+          return $
+            GitDirectoryInfo
+              <$> parseTime onError (head timestamps)
+    _ ->
+      return $ onError $ GitProcessError status stdout stderr
+  where
+    parseTime onError s = maybe (onError $ GitTimeParseError s) Right $ iso8601ParseM s
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,14 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main where
+
+import Test.Tasty
+
+import qualified Spec.SyncSpec as SyncSpec
+
+main :: IO ()
+main = do
+    defaultMain $
+      testGroup "Tests"
+        [ SyncSpec.spec
+        ]
diff --git a/test/Spec/SyncSpec.hs b/test/Spec/SyncSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec/SyncSpec.hs
@@ -0,0 +1,86 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Spec.SyncSpec (spec) where
+
+import Control.Monad (unless)
+import Data.Bifunctor (first)
+import Security.Advisories.Sync
+import qualified System.Directory as D
+import System.Environment (lookupEnv)
+import System.Exit (ExitCode (ExitSuccess))
+import System.FilePath ((</>))
+import System.IO.Temp (withSystemTempDirectory)
+import System.Process (readProcessWithExitCode)
+import Test.Tasty
+import Test.Tasty.HUnit
+
+spec :: TestTree
+spec =
+  testGroup
+    "Sync"
+    [ testGroup
+        "sync"
+        [ testCase "Invalid root should fail" $ do
+            let repo = withRepositoryAt "/dev/advisories"
+            status repo >>= (@?= DirectoryMissing)
+            isGitHubActionRunner <- lookupEnv "GITHUB_ACTIONS"
+            unless (isGitHubActionRunner == Just "true") $ do
+              -- GitHub Action runners let you write anywhere
+              result <- sync repo
+              first (const ("<Redacted error>" :: String)) result @?= Left "<Redacted error>"
+            status repo >>= (@?= DirectoryMissing),
+          testCase "Subdirectory creation should work" $
+            withSystemTempDirectory "hsec-sync" $ \p -> do
+              let repo = withRepositoryAt $ p </> "repo"
+              status repo >>= (@?= DirectoryMissing)
+              result <- sync repo
+              result @?= Right Created
+              status repo >>= (@?= DirectoryUpToDate),
+          testCase "With existing subdirectory creation should work" $
+            withSystemTempDirectory "hsec-sync" $ \p -> do
+              D.createDirectory $ p </> "repo"
+              let repo = withRepositoryAt $ p </> "repo"
+              result <- sync repo
+              result @?= Right Created,
+          testCase "Sync twice should be a no-op" $
+            withSystemTempDirectory "hsec-sync" $ \p -> do
+              let repo = withRepositoryAt p
+              status repo >>= (@?= DirectoryEmpty)
+              resultCreate <- sync repo
+              resultCreate @?= Right Created
+              resultResync <- sync repo
+              resultResync @?= Right AlreadyUpToDate,
+          testCase "Sync behind should update" $
+            withSystemTempDirectory "hsec-sync" $ \p -> do
+              let repo = withRepositoryAt p
+              resultCreate <- sync repo
+              resultCreate @?= Right Created
+              D.withCurrentDirectory p $ do
+                (statusReset, _, _) <-
+                  readProcessWithExitCode "git" ["reset", "--hard", "HEAD~50"] ""
+                statusReset @?= ExitSuccess
+              status repo >>= (@?= DirectoryOutDated)
+              resultResync <- sync repo
+              resultResync @?= Right Updated
+              status repo >>= (@?= DirectoryUpToDate),
+          testCase "Sync behind and changed remote should update" $
+            withSystemTempDirectory "hsec-sync" $ \p -> do
+              let repo = withRepositoryAt p
+              resultCreate <- sync repo
+              resultCreate @?= Right Created
+              D.withCurrentDirectory p $ do
+                (statusReset, _, _) <-
+                  readProcessWithExitCode "git" ["reset", "--hard", "HEAD~50"] ""
+                statusReset @?= ExitSuccess
+                (statusRemote, _, _) <-
+                  readProcessWithExitCode "git" ["remote", "rename", "origin", "old"] ""
+                statusRemote @?= ExitSuccess
+              resultResync <- sync repo
+              resultResync @?= Right Updated
+        ]
+    ]
+
+withRepositoryAt :: FilePath -> Repository
+withRepositoryAt root =
+  defaultRepository {repositoryRoot = root}
